feat: deterministic match_dirs tie-breaking by config file order
This commit is contained in:
parent
ac780231a8
commit
830eba1c0e
2 changed files with 113 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ type HostConfig struct {
|
||||||
User string `yaml:"user,omitempty"`
|
User string `yaml:"user,omitempty"`
|
||||||
GitProtocol string `yaml:"git_protocol,omitempty"`
|
GitProtocol string `yaml:"git_protocol,omitempty"`
|
||||||
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
||||||
|
Order int `yaml:"-"` // config file order, set at load time
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigDir() (string, error) {
|
func GetConfigDir() (string, error) {
|
||||||
|
|
@ -67,9 +68,43 @@ func LoadFromPath(path string) (*Config, error) {
|
||||||
cfg.Hosts = make(map[string]HostConfig)
|
cfg.Hosts = make(map[string]HostConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse again with yaml.Node to capture config file order for hosts
|
||||||
|
assignHostOrder(&cfg, data)
|
||||||
|
|
||||||
return &cfg, nil
|
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 {
|
func (c *Config) Save() error {
|
||||||
path, err := GetConfigPath()
|
path, err := GetConfigPath()
|
||||||
if err != nil {
|
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.
|
// prefix of cwd. Returns "" if no match is found.
|
||||||
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
||||||
// to handle symlinks (e.g. macOS /tmp → /private/tmp).
|
// 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 {
|
func (c *Config) ResolveHostByPath(cwd string) string {
|
||||||
if cwd == "" {
|
if cwd == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -145,6 +182,8 @@ func (c *Config) ResolveHostByPath(cwd string) string {
|
||||||
|
|
||||||
bestHost := ""
|
bestHost := ""
|
||||||
bestLen := 0
|
bestLen := 0
|
||||||
|
bestOrder := 0
|
||||||
|
tied := false
|
||||||
|
|
||||||
for hostname, host := range c.Hosts {
|
for hostname, host := range c.Hosts {
|
||||||
for _, dir := range host.MatchDirs {
|
for _, dir := range host.MatchDirs {
|
||||||
|
|
@ -167,11 +206,24 @@ func (c *Config) ResolveHostByPath(cwd string) string {
|
||||||
if len(dir) > bestLen {
|
if len(dir) > bestLen {
|
||||||
bestLen = len(dir)
|
bestLen = len(dir)
|
||||||
bestHost = hostname
|
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
|
return bestHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestExpandHome(t *testing.T) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue