From 830eba1c0ede232d715497e2c52539846169e770 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 12:55:49 -0600 Subject: [PATCH] feat: deterministic match_dirs tie-breaking by config file order --- internal/config/config.go | 52 +++++++++++++++++++++++++++++ internal/config/config_test.go | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 8fec3fa..e60583d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type HostConfig struct { User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` MatchDirs []string `yaml:"match_dirs,omitempty"` + Order int `yaml:"-"` // config file order, set at load time } func GetConfigDir() (string, error) { @@ -67,9 +68,43 @@ func LoadFromPath(path string) (*Config, error) { cfg.Hosts = make(map[string]HostConfig) } + // Parse again with yaml.Node to capture config file order for hosts + assignHostOrder(&cfg, data) + return &cfg, nil } +// assignHostOrder walks the YAML document tree to find the "hosts" mapping +// and stamps each HostConfig.Order with its position in the file. +func assignHostOrder(cfg *Config, data []byte) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil || len(doc.Content) == 0 { + return + } + root := doc.Content[0] // mapping node + if root.Kind != yaml.MappingNode { + return + } + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "hosts" { + hostsNode := root.Content[i+1] + if hostsNode.Kind != yaml.MappingNode { + return + } + order := 0 + for j := 0; j+1 < len(hostsNode.Content); j += 2 { + key := hostsNode.Content[j].Value + if h, ok := cfg.Hosts[key]; ok { + h.Order = order + cfg.Hosts[key] = h + order++ + } + } + return + } + } +} + func (c *Config) Save() error { path, err := GetConfigPath() if err != nil { @@ -133,6 +168,8 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host // 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). +// On ties (same prefix length from multiple hosts), the host appearing first +// in the config file wins and a warning is printed to stderr. func (c *Config) ResolveHostByPath(cwd string) string { if cwd == "" { return "" @@ -145,6 +182,8 @@ func (c *Config) ResolveHostByPath(cwd string) string { bestHost := "" bestLen := 0 + bestOrder := 0 + tied := false for hostname, host := range c.Hosts { for _, dir := range host.MatchDirs { @@ -167,11 +206,24 @@ func (c *Config) ResolveHostByPath(cwd string) string { if len(dir) > bestLen { bestLen = len(dir) bestHost = hostname + bestOrder = host.Order + tied = false + } else if len(dir) == bestLen && hostname != bestHost { + // Tie — pick the host with the lower Order (earlier in config) + if host.Order < bestOrder { + bestHost = hostname + bestOrder = host.Order + } + tied = true } } } } + if tied { + fmt.Fprintf(os.Stderr, "warning: multiple hosts match directory %q with the same specificity; using %s (first in config)\n", cwd, bestHost) + } + return bestHost } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af2d79e..8ceae65 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -567,6 +567,67 @@ func TestResolveHostByPath_TildeExpansion(t *testing.T) { } } +func TestResolveHostByPath_TieBreakByConfigOrder(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "second.org": { + Hostname: "second.org", + Token: "t2", + MatchDirs: []string{"/shared/path"}, + Order: 1, + }, + "first.org": { + Hostname: "first.org", + Token: "t1", + MatchDirs: []string{"/shared/path"}, + Order: 0, + }, + }, + } + + got := cfg.ResolveHostByPath("/shared/path/subdir") + if got != "first.org" { + t.Errorf("expected first.org (earlier in config), got %q", got) + } +} + +func TestAssignHostOrder(t *testing.T) { + yamlData := []byte(`hosts: + alpha.org: + hostname: alpha.org + token: t1 + beta.org: + hostname: beta.org + token: t2 + gamma.org: + hostname: gamma.org + token: t3 +`) + cfg, err := LoadFromPath(writeTempConfig(t, yamlData)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Hosts["alpha.org"].Order != 0 { + t.Errorf("alpha.org order = %d, want 0", cfg.Hosts["alpha.org"].Order) + } + if cfg.Hosts["beta.org"].Order != 1 { + t.Errorf("beta.org order = %d, want 1", cfg.Hosts["beta.org"].Order) + } + if cfg.Hosts["gamma.org"].Order != 2 { + t.Errorf("gamma.org order = %d, want 2", cfg.Hosts["gamma.org"].Order) + } +} + +func writeTempConfig(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + return path +} + func TestExpandHome(t *testing.T) { home, err := os.UserHomeDir() if err != nil {