From 3758ddf1742c641a9fe75b4109e487e881151e15 Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Thu, 20 Aug 2026 13:22:36 +0200 Subject: [PATCH] feat(config): read [[context]] rules from global git config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #145 shipped context rules with the config file as their only source, which left them as the one setting that could not live in git config — awkward for a feature modelled on git's own includeIf, which is git config. git config --global wt.context.work.whenpath "~/dev/repos/work" git config --global --add wt.context.work.env "WT_CATEGORY=work" git config --global --add wt.context.work.env "WT_ORG=acme" The loader kept one value per key, which is right for every scalar setting but loses all but the last variable of a rule. It now returns entries in the order git listed them, and gitConfigValues collapses them to last-wins for the scalars, so their behaviour is unchanged. Order is kept because it is what decides how rules compose. The two sources compose rather than replace: git config rules are evaluated first, then the config file's, under the same later-definitions-win rule that already governs rules within one source. So the config file wins wherever both cover the same path — the documented precedence — while a git config rule for an unrelated tree keeps working instead of vanishing the moment a [[context]] block is added to the file. Global scope only. A rule scoped to one repository is redundant, since that repository could set wt.pattern directly, and keeping rules out of --local holds the same user-owned line that already keeps them out of a committed .wt.toml. The system scope is skipped for a duller reason: wt reads no system git config for any setting, and making this the exception would be worse than the gap. Closes #146 --- README.md | 11 +- cmd/config.go | 82 ++++++++++---- cmd/config_test.go | 28 +++-- cmd/context.go | 100 +++++++++++++++- cmd/context_test.go | 257 +++++++++++++++++++++++++++++++++++++++++- docs/configuration.md | 55 ++++++++- 6 files changed, 488 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 52d342e..c6a98f2 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,15 @@ env = { WT_CATEGORY = "work" } ``` Every `wt` command operating on a repo under that path then resolves `work`, -including `wt create` from a worktree in a different tree. See -[Setting the category per directory](docs/configuration.md#setting-the-category-per-directory). +including `wt create` from a worktree in a different tree. The same rule can go +in `~/.gitconfig` instead, if you would rather not keep a config file: + +```bash +git config --global wt.context.work.whenpath "~/dev/repos/work" +git config --global --add wt.context.work.env "WT_CATEGORY=work" +``` + +See [Setting the category per directory](docs/configuration.md#setting-the-category-per-directory). ### Checkout & Create diff --git a/cmd/config.go b/cmd/config.go index 43fd396..6191d10 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -134,23 +134,34 @@ const ( gitScopeLocal gitConfigScope = "local" ) +// gitConfigEntry is one key/value record as git reported it. +// +// A slice rather than a map because two properties are needed that a map cannot +// carry: repeated keys (a context rule sets one `env` key once per variable, +// with `--add`) and the order git listed them in, which is the order rules are +// composed in. +type gitConfigEntry struct { + // Key is the full dotted name. git lowercases the section and the variable + // name but preserves the case of any subsection, and that is kept as-is + // here so a rule's name survives; lowercase for lookups. + Key string + Value string +} + // gitConfigFn reads the wt.* keys from a single git config scope. // It is a variable so tests can inject a fake implementation. var gitConfigFn = defaultGitConfig -// defaultGitConfig reads wt.* keys from the given git config scope. -// -// Only the last value of each key is kept: git config returns multi-valued keys -// in lowest-to-highest precedence order, and for scalar settings the final one -// is the effective value. +// defaultGitConfig reads wt.* keys from the given git config scope, in the +// order git reports them. // // A missing scope is not an error. `git config --get-regexp` exits 1 when no key // matches, and exits with other non-zero codes when the scope is unavailable // (for example --local outside a repository). Both mean "nothing configured -// here", so any error yields an empty map rather than failing the command. This +// here", so any error yields no entries rather than failing the command. This // matches how a malformed config file is treated on the TOML path, which is // also skipped rather than reported. -func defaultGitConfig(scope gitConfigScope) map[string]string { +func defaultGitConfig(scope gitConfigScope) []gitConfigEntry { // --null makes the output unambiguous: records are NUL-separated and the // key is separated from its value by a newline, so values containing // spaces or newlines survive intact. A key set with no value at all has no @@ -161,7 +172,7 @@ func defaultGitConfig(scope gitConfigScope) map[string]string { return nil } - values := make(map[string]string) + var entries []gitConfigEntry for _, record := range strings.Split(string(output), "\x00") { if record == "" { continue @@ -169,14 +180,28 @@ func defaultGitConfig(scope gitConfigScope) map[string]string { key, value, hasValue := strings.Cut(record, "\n") if !hasValue { // `[wt]\n\tseparator` with no "=": git reports it as valueless. - // There is no scalar setting for which that is meaningful, so it - // is treated as unset rather than as an empty string. + // There is no setting for which that is meaningful, so it is + // treated as unset rather than as an empty string. continue } - // git lowercases the section and the variable name, but preserves the - // case of any subsection. Only the fully lowercase keys are read, so - // normalising here keeps lookups simple. - values[strings.ToLower(key)] = value + entries = append(entries, gitConfigEntry{Key: key, Value: value}) + } + return entries +} + +// gitConfigValues collapses entries to one value per key, for the scalar +// settings. +// +// The last value wins: git lists multi-valued keys in lowest-to-highest +// precedence order, and for a scalar the final one is the effective value. +// Keys are lowercased so lookups can be written in one spelling. +func gitConfigValues(entries []gitConfigEntry) map[string]string { + if len(entries) == 0 { + return nil + } + values := make(map[string]string, len(entries)) + for _, entry := range entries { + values[strings.ToLower(entry.Key)] = entry.Value } return values } @@ -186,8 +211,8 @@ func defaultGitConfig(scope gitConfigScope) map[string]string { // possible via multi-valued keys, but the merge semantics (replace vs append, // and how to clear inherited hooks) are not settled for the TOML sources // either, so git config stays scalar-only. -func applyGitConfig(scope gitConfigScope, sourceLabel string) { - values := gitConfigFn(scope) +func applyGitConfig(entries []gitConfigEntry, sourceLabel string) { + values := gitConfigValues(entries) if len(values) == 0 { return } @@ -272,6 +297,10 @@ const defaultConfigTemplate = `# wt configuration file # Matched against the repository's main checkout for worktree commands, and # against the current directory for 'wt clone' (no repo exists yet). # +# The same rules can live in ~/.gitconfig instead (this file wins on conflict): +# git config --global wt.context.work.whenpath "~/dev/repos/work" +# git config --global --add wt.context.work.env "WT_CATEGORY=work" +# # [[context]] # when_path = "~/dev/repos/work" # env = { WT_CATEGORY = "work" } @@ -370,8 +399,11 @@ func loadWorktreeConfig() { repoPattern = defaultRepoPattern // 2. Global git config (~/.gitconfig) — the broadest fallback, below wt's - // own config file. - applyGitConfig(gitScopeGlobal, "git config (global)") + // own config file. Read once and used twice: the scalar settings, and the + // wt.context.* rules, which no other git scope may supply. + globalGit := gitConfigFn(gitScopeGlobal) + applyGitConfig(globalGit, "git config (global)") + contextRules = contextRulesFromGitConfig(globalGit) // 3. Load config file configFilePath = resolveConfigPath(configFlag) @@ -415,7 +447,15 @@ func loadWorktreeConfig() { hooksPolicy = strings.ToLower(strings.TrimSpace(cfg.HooksPolicy)) configSources.HooksPolicy = "config file" } - contextRules = cfg.Context + // Appended after the git config rules rather than replacing them, + // so the two sources compose the way rules within one source + // already do: every matching rule applies, later definitions win + // per variable. Because these come last, the config file wins + // wherever both sources set the same variable for the same path — + // which is the documented precedence — while a git config rule for + // some unrelated tree keeps working instead of silently vanishing + // the moment a [[context]] block is added to this file. + contextRules = append(contextRules, cfg.Context...) } } @@ -490,7 +530,9 @@ func loadWorktreeConfig() { // // Linked worktrees share the main repository's .git/config, so a value set // once in the main checkout applies from every worktree of that repo. - applyGitConfig(gitScopeLocal, "git config (local)") + // + // wt.context.* is deliberately not read here — see contextRulesFromGitConfig. + applyGitConfig(gitConfigFn(gitScopeLocal), "git config (local)") // 6. Environment variables override every file-based source if v := os.Getenv("WORKTREE_ROOT"); v != "" { diff --git a/cmd/config_test.go b/cmd/config_test.go index 63c5ab7..350d0fe 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -345,7 +345,7 @@ func TestLoadWorktreeConfig(t *testing.T) { // Isolate from the developer's real git config: a contributor with wt.* // keys in ~/.gitconfig would otherwise fail these tests. - gitConfigFn = func(gitConfigScope) map[string]string { return nil } + gitConfigFn = func(gitConfigScope) []gitConfigEntry { return nil } t.Run("loads defaults when no config file", func(t *testing.T) { os.Setenv("WORKTREE_ROOT", "") @@ -630,7 +630,7 @@ func TestLoadWorktreeConfigRepoConfig(t *testing.T) { os.Setenv("WT_CONFIG", "/nonexistent/config.toml") configFlag = "" // Isolate from the developer's real git config. - gitConfigFn = func(gitConfigScope) map[string]string { return nil } + gitConfigFn = func(gitConfigScope) []gitConfigEntry { return nil } } t.Run("repo config overrides global config", func(t *testing.T) { @@ -878,17 +878,27 @@ func TestLoadWorktreeConfigGitConfig(t *testing.T) { os.Setenv("WT_CONFIG", "/nonexistent/config.toml") configFlag = "" gitRepoRootFn = func() (string, error) { return t.TempDir(), nil } - gitConfigFn = func(gitConfigScope) map[string]string { return nil } + gitConfigFn = func(gitConfigScope) []gitConfigEntry { return nil } } - // stubGitConfig serves per-scope values to the loader. + // stubGitConfig serves per-scope values to the loader. The map form is fine + // for the scalar settings these subtests cover: each key appears once, so + // the order entries are produced in cannot change the outcome. Ordered + // entries are exercised directly in context_test.go. stubGitConfig := func(global, local map[string]string) { - gitConfigFn = func(scope gitConfigScope) map[string]string { + entries := func(values map[string]string) []gitConfigEntry { + var out []gitConfigEntry + for key, value := range values { + out = append(out, gitConfigEntry{Key: key, Value: value}) + } + return out + } + gitConfigFn = func(scope gitConfigScope) []gitConfigEntry { switch scope { case gitScopeGlobal: - return global + return entries(global) case gitScopeLocal: - return local + return entries(local) } return nil } @@ -1124,7 +1134,7 @@ func TestDefaultGitConfigParsing(t *testing.T) { } t.Cleanup(func() { os.Chdir(origDir) }) - values := defaultGitConfig(gitScopeLocal) + values := gitConfigValues(defaultGitConfig(gitScopeLocal)) // Last value wins for a multi-valued key. if values["wt.strategy"] != "sibling-repo" { @@ -1187,7 +1197,7 @@ func TestDefaultGitConfigMultilineAndValuelessKeys(t *testing.T) { } t.Cleanup(func() { os.Chdir(origDir) }) - values := defaultGitConfig(gitScopeLocal) + values := gitConfigValues(defaultGitConfig(gitScopeLocal)) if values["wt.pattern"] != "line-one\nline-two" { t.Errorf("wt.pattern = %q, want the embedded newline preserved", values["wt.pattern"]) diff --git a/cmd/context.go b/cmd/context.go index 0cf3af8..0f10d36 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -21,14 +21,104 @@ type ContextRule struct { Env map[string]string `toml:"env"` } -// contextRules holds the rules from the user's config file, in file order. +// contextRules holds the effective rules in the order they are composed: +// global git config first, then the user's config file. // -// Read from the config file only — never from a repo's committed .wt.toml, and -// not from local git config. A cloned repository must not be able to redirect -// where your worktrees land, which is the same boundary that keeps root, -// repo_root and repo_pattern out of .wt.toml. +// Read from those two sources only — never from a repo's committed .wt.toml, +// and not from local git config. A cloned repository must not be able to +// redirect where your worktrees land, which is the same boundary that keeps +// root, repo_root and repo_pattern out of .wt.toml. var contextRules []ContextRule +// gitContextPrefix is the dotted key prefix a context rule lives under: +// `wt.context..` parses as section wt, subsection context., +// variable . The subsection is what gives a rule a name, so a later +// `git config --global --remove-section 'wt.context.work'` can target it. +const gitContextPrefix = "wt.context." + +// contextRulesFromGitConfig parses wt.context.* entries into rules. +// +// git config --global wt.context.work.whenpath "~/dev/repos/work" +// git config --global --add wt.context.work.env "WT_CATEGORY=work" +// git config --global --add wt.context.work.env "WT_ORG=acme" +// +// Rules keep the order git listed them in, first appearance of a name deciding +// its position, so composition follows the file rather than the map iteration +// order Go would otherwise hand out. +// +// `env` is multi-valued — one NAME=VALUE per entry, added with `--add`. A value +// without "=" or with an empty name is malformed and skipped rather than +// guessed at; there is no reading of a bare "WT_CATEGORY" that is obviously +// right, and silently inventing an empty value would collapse a path segment. +// +// Only ever called with the global scope. A path rule scoped to a single +// repository is redundant — that repo could set wt.pattern directly — and +// keeping rules out of --local preserves the user-owned/repo-owned boundary, +// for the same reason a committed .wt.toml cannot supply them. The system scope +// is not read either, but for a duller reason: wt reads no system git config at +// all today, and adding it for this one setting would mean wt.context.* obeyed +// a scope that wt.root ignored. +func contextRulesFromGitConfig(entries []gitConfigEntry) []ContextRule { + var ( + rules []ContextRule + index = map[string]int{} + ) + + for _, entry := range entries { + // git preserves the case of a subsection, so `context` itself may be + // spelled with capitals even though `wt` and the variable are not. + // Match case-insensitively, then slice the original key so the rule's + // own name keeps the case it was written with. + lower := strings.ToLower(entry.Key) + if !strings.HasPrefix(lower, gitContextPrefix) { + continue + } + rest := entry.Key[len(gitContextPrefix):] + + // The name may contain dots of its own (`wt.context.acme.api.env`), so + // split on the last one: everything before it names the rule. + dot := strings.LastIndex(rest, ".") + if dot <= 0 { + continue + } + name, field := rest[:dot], strings.ToLower(rest[dot+1:]) + + // Resolve the value before the rule is created, so an unrecognised + // field or a malformed env entry does not leave an empty rule behind. + var envKey, envValue string + switch field { + case "whenpath": + case "env": + key, value, ok := strings.Cut(entry.Value, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + continue + } + envKey, envValue = key, strings.TrimSpace(value) + default: + continue + } + + at, seen := index[name] + if !seen { + at = len(rules) + index[name] = at + rules = append(rules, ContextRule{}) + } + + if field == "whenpath" { + rules[at].WhenPath = strings.TrimSpace(entry.Value) + continue + } + if rules[at].Env == nil { + rules[at].Env = map[string]string{} + } + rules[at].Env[envKey] = envValue + } + + return rules +} + // contextEnv returns the variables the matching rules supply for target. // // Every matching rule applies and later definitions win per variable, so a diff --git a/cmd/context_test.go b/cmd/context_test.go index 68281e2..55350cd 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -16,6 +17,254 @@ func withRules(t *testing.T, rules []ContextRule) { t.Cleanup(func() { contextRules = prev }) } +// withGitConfig serves fixed entries per scope to loadWorktreeConfig. +// +// Every test that calls the loader needs this even when it cares about nothing +// in git config: without it the developer's own ~/.gitconfig is read, and a +// real wt.context.* rule there would leak into the assertions. +func withGitConfig(t *testing.T, global, local []gitConfigEntry) { + t.Helper() + prev := gitConfigFn + gitConfigFn = func(scope gitConfigScope) []gitConfigEntry { + switch scope { + case gitScopeGlobal: + return global + case gitScopeLocal: + return local + } + return nil + } + t.Cleanup(func() { gitConfigFn = prev }) +} + +// withoutRepoConfig stops the loader from picking up whatever repository the +// test binary happens to be running inside. +func withoutRepoConfig(t *testing.T) { + t.Helper() + prev := gitRepoRootFn + gitRepoRootFn = func() (string, error) { return "", os.ErrNotExist } + t.Cleanup(func() { gitRepoRootFn = prev }) +} + +// withoutConfigFile points the loader at a path that does not exist, leaving +// git config as the only source of rules. +func withoutConfigFile(t *testing.T) { + t.Helper() + withoutRepoConfig(t) + prev := configFlag + configFlag = filepath.Join(t.TempDir(), "absent.toml") + t.Cleanup(func() { configFlag = prev }) +} + +func TestContextRulesFromGitConfig(t *testing.T) { + t.Run("whenpath and multi-valued env", func(t *testing.T) { + rules := contextRulesFromGitConfig([]gitConfigEntry{ + {Key: "wt.context.work.whenpath", Value: "~/dev/repos/work"}, + {Key: "wt.context.work.env", Value: "WT_CATEGORY=work"}, + {Key: "wt.context.work.env", Value: "WT_ORG=acme"}, + }) + + if len(rules) != 1 { + t.Fatalf("parsed %d rules, want 1", len(rules)) + } + if rules[0].WhenPath != "~/dev/repos/work" { + t.Errorf("WhenPath = %q, want ~/dev/repos/work", rules[0].WhenPath) + } + // Both values survive: this is the whole reason the loader keeps + // entries in a slice instead of collapsing them to one per key. + if rules[0].Env["WT_CATEGORY"] != "work" || rules[0].Env["WT_ORG"] != "acme" { + t.Errorf("Env = %v, want both WT_CATEGORY=work and WT_ORG=acme", rules[0].Env) + } + }) + + t.Run("rules keep the order git listed them in", func(t *testing.T) { + rules := contextRulesFromGitConfig([]gitConfigEntry{ + {Key: "wt.context.broad.whenpath", Value: "/a"}, + {Key: "wt.context.broad.env", Value: "X=1"}, + {Key: "wt.context.narrow.whenpath", Value: "/a/b"}, + {Key: "wt.context.narrow.env", Value: "X=2"}, + // A second entry for the first rule must not move it to the end: + // position is decided by first appearance, so composition follows + // the config file. + {Key: "wt.context.broad.env", Value: "Y=3"}, + }) + + if len(rules) != 2 { + t.Fatalf("parsed %d rules, want 2", len(rules)) + } + if rules[0].WhenPath != "/a" || rules[1].WhenPath != "/a/b" { + t.Fatalf("order = %q, %q; want /a then /a/b", rules[0].WhenPath, rules[1].WhenPath) + } + if rules[0].Env["Y"] != "3" { + t.Errorf("rules[0].Env = %v, want the later Y=3 merged in", rules[0].Env) + } + }) + + t.Run("rule name may contain dots", func(t *testing.T) { + rules := contextRulesFromGitConfig([]gitConfigEntry{ + {Key: "wt.context.acme.api.whenpath", Value: "/srv"}, + {Key: "wt.context.acme.api.env", Value: "X=1"}, + }) + if len(rules) != 1 { + t.Fatalf("parsed %d rules, want 1 (name split on the last dot)", len(rules)) + } + if rules[0].WhenPath != "/srv" || rules[0].Env["X"] != "1" { + t.Errorf("rule = %+v, want WhenPath=/srv and X=1", rules[0]) + } + }) + + t.Run("subsection case is not required to match", func(t *testing.T) { + // git preserves subsection case, so `[wt "Context.Work"]` reaches us + // spelled that way. The name keeps its case; the prefix must not care. + rules := contextRulesFromGitConfig([]gitConfigEntry{ + {Key: "wt.Context.Work.whenpath", Value: "/w"}, + {Key: "wt.Context.Work.env", Value: "X=1"}, + }) + if len(rules) != 1 || rules[0].WhenPath != "/w" || rules[0].Env["X"] != "1" { + t.Fatalf("rules = %+v, want one rule with WhenPath=/w and X=1", rules) + } + }) + + t.Run("malformed and unrelated entries leave no rule behind", func(t *testing.T) { + rules := contextRulesFromGitConfig([]gitConfigEntry{ + {Key: "wt.root", Value: "/scalar"}, // not a rule + {Key: "wt.context.a.env", Value: "NOEQUALS"}, // no "=" + {Key: "wt.context.b.env", Value: "=orphan"}, // empty name + {Key: "wt.context.c.unknown", Value: "x"}, // unknown field + {Key: "wt.context.whenpath", Value: "/no-name"}, // no rule name + {Key: "wt.contextual.d.env", Value: "X=1"}, // different subsection + {Key: "wt.context.e.env", Value: " WT_X = spaced "}, // trimmed, kept + }) + + if len(rules) != 1 { + t.Fatalf("parsed %+v, want only the one well-formed rule", rules) + } + if rules[0].Env["WT_X"] != "spaced" { + t.Errorf("Env = %v, want WT_X=spaced with surrounding space trimmed", rules[0].Env) + } + }) + + t.Run("no entries", func(t *testing.T) { + if rules := contextRulesFromGitConfig(nil); len(rules) != 0 { + t.Fatalf("contextRulesFromGitConfig(nil) = %+v, want none", rules) + } + }) +} + +// The real `git config` round trip: proves the key shape documented for users +// is the one that comes back, and that --add really yields repeated entries. +func TestContextRulesFromRealGitConfig(t *testing.T) { + repoDir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + + run("init", "-q", ".") + run("config", "--local", "wt.context.work.whenpath", "~/dev/repos/work") + run("config", "--local", "--add", "wt.context.work.env", "WT_CATEGORY=work") + run("config", "--local", "--add", "wt.context.work.env", "WT_ORG=acme") + run("config", "--local", "wt.context.personal.whenpath", "~/dev/repos/personal") + run("config", "--local", "--add", "wt.context.personal.env", "WT_CATEGORY=personal") + + t.Chdir(repoDir) + + rules := contextRulesFromGitConfig(defaultGitConfig(gitScopeLocal)) + if len(rules) != 2 { + t.Fatalf("parsed %d rules from real git config, want 2: %+v", len(rules), rules) + } + if rules[0].WhenPath != "~/dev/repos/work" { + t.Errorf("rules[0].WhenPath = %q, want ~/dev/repos/work", rules[0].WhenPath) + } + if rules[0].Env["WT_CATEGORY"] != "work" || rules[0].Env["WT_ORG"] != "acme" { + t.Errorf("rules[0].Env = %v, want WT_CATEGORY=work and WT_ORG=acme", rules[0].Env) + } + if rules[1].Env["WT_CATEGORY"] != "personal" { + t.Errorf("rules[1].Env = %v, want WT_CATEGORY=personal", rules[1].Env) + } +} + +func TestGlobalGitConfigLoadsContextRules(t *testing.T) { + withoutConfigFile(t) + withGitConfig(t, []gitConfigEntry{ + {Key: "wt.context.work.whenpath", Value: "~/dev/repos/work"}, + {Key: "wt.context.work.env", Value: "WT_CATEGORY=work"}, + }, nil) + + loadWorktreeConfig() + + if len(contextRules) != 1 { + t.Fatalf("loaded %d rules, want 1", len(contextRules)) + } + if contextRules[0].Env["WT_CATEGORY"] != "work" { + t.Errorf("rule = %+v, want WT_CATEGORY=work", contextRules[0]) + } +} + +// Local git config is .git/config — shared by every worktree of one repository, +// and therefore the wrong scope for a rule that decides where repositories go. +// Keeping it out also holds the same user-owned line as the .wt.toml refusal. +func TestLocalGitConfigCannotSupplyContextRules(t *testing.T) { + withoutConfigFile(t) + withGitConfig(t, nil, []gitConfigEntry{ + {Key: "wt.context.sneaky.whenpath", Value: "/"}, + {Key: "wt.context.sneaky.env", Value: "WT_CATEGORY=attacker"}, + }) + + loadWorktreeConfig() + + if len(contextRules) != 0 { + t.Fatalf("local git config supplied %d rules, want 0: %+v", len(contextRules), contextRules) + } +} + +// The two sources compose rather than one replacing the other: a config file +// rule wins where both cover the same path, but a git config rule for an +// unrelated tree keeps working instead of disappearing the moment a [[context]] +// block is added to the file. +func TestConfigFileRulesComposeOverGitConfigRules(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + body := strings.Join([]string{ + `[[context]]`, + `when_path = "/shared"`, + `env = { WT_CATEGORY = "from-file" }`, + }, "\n") + if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + prevFlag := configFlag + configFlag = cfgPath + t.Cleanup(func() { configFlag = prevFlag }) + + withoutRepoConfig(t) + withGitConfig(t, []gitConfigEntry{ + {Key: "wt.context.shared.whenpath", Value: "/shared"}, + {Key: "wt.context.shared.env", Value: "WT_CATEGORY=from-git"}, + {Key: "wt.context.shared.env", Value: "WT_ORG=acme"}, + {Key: "wt.context.other.whenpath", Value: "/other"}, + {Key: "wt.context.other.env", Value: "WT_CATEGORY=git-only"}, + }, nil) + + loadWorktreeConfig() + + shared := contextEnv("/shared/acme/api") + if shared["WT_CATEGORY"] != "from-file" { + t.Errorf("WT_CATEGORY = %q, want from-file (config file outranks git config)", shared["WT_CATEGORY"]) + } + if shared["WT_ORG"] != "acme" { + t.Errorf("WT_ORG = %q, want acme (the git rule's other variable survives)", shared["WT_ORG"]) + } + if got := contextEnv("/other/acme"); got["WT_CATEGORY"] != "git-only" { + t.Errorf("WT_CATEGORY = %q, want git-only (an unrelated git rule is not dropped)", got["WT_CATEGORY"]) + } +} + func TestContextEnvMatching(t *testing.T) { root := t.TempDir() work := filepath.Join(root, "work") @@ -249,11 +498,8 @@ func TestConfigFileLoadsContextRules(t *testing.T) { configFlag = cfgPath t.Cleanup(func() { configFlag = prevFlag }) - // gitRepoRootFn is stubbed so the loader does not pick up whatever - // repository the test binary happens to be running inside. - prevRepoRoot := gitRepoRootFn - gitRepoRootFn = func() (string, error) { return "", os.ErrNotExist } - t.Cleanup(func() { gitRepoRootFn = prevRepoRoot }) + withoutRepoConfig(t) + withGitConfig(t, nil, nil) loadWorktreeConfig() @@ -294,6 +540,7 @@ func TestRepoConfigCannotSupplyContextRules(t *testing.T) { prevRepoRoot := gitRepoRootFn gitRepoRootFn = func() (string, error) { return repoDir, nil } t.Cleanup(func() { gitRepoRootFn = prevRepoRoot }) + withGitConfig(t, nil, nil) loadWorktreeConfig() diff --git a/docs/configuration.md b/docs/configuration.md index b55803d..0ab5920 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,6 +77,7 @@ git config --global wt.root ~/projects/worktrees | `wt.pattern` | `pattern` | | `wt.separator` | `separator` | | `wt.repo_pattern` | `repo_pattern` | +| `wt.context..whenpath` / `.env` | `[[context]]` (**`--global` only**) | Notes: @@ -85,6 +86,8 @@ Notes: local state rather than project policy arriving through a pull request. - Linked worktrees share the main repository's `.git/config`, so `--local` settings apply from every worktree of that repo. +- `wt.context.*` is the exception to that: it is read from `--global` only. + See [Setting the category per directory](#setting-the-category-per-directory). ## Precedence @@ -315,10 +318,54 @@ WT_CATEGORY=oss wt create feat/x That includes a variable exported as empty — `WT_CATEGORY= wt clone timvw/wt` collapses the segment away rather than picking up a rule's value. -Rules are read from **your config file only** — never from a repository's -committed `.wt.toml`, and not from `git config`. A repository you clone must not -be able to redirect where your worktrees land, the same reason `root`, -`repo_root` and `repo_pattern` are excluded from `.wt.toml`. +#### In global git config + +Rules can live in `~/.gitconfig` instead, if you would rather not keep a `wt` +config file at all: + +```bash +git config --global wt.context.work.whenpath "~/dev/repos/work" +git config --global --add wt.context.work.env "WT_CATEGORY=work" +git config --global --add wt.context.work.env "WT_ORG=acme" +``` + +`wt.context..` is a git subsection, so `` is just a handle for +the rule — it lets you come back and remove one: + +```bash +git config --global --remove-section 'wt.context.work' +``` + +Three things to know: + +- **`env` is multi-valued.** Use `--add` once per variable; plain `git config` + replaces the whole key. Each value is a single `NAME=VALUE` pair, and one + without an `=` is ignored. +- **git lowercases key names**, so it is `whenpath`, not `whenPath`. A + camel-cased spelling written by hand into `~/.gitconfig` is silently ignored. + (Subsection names keep their case, so `` can be spelled however you + like.) +- **The config file wins.** Global git config sits *below* the config file in + the [precedence](#precedence) order — level 5 against level 4. This is the + reverse of `--local` git config at level 2, so "git config beats the config + file" only holds for `--local`. + +The two sources compose rather than replace: rules from `~/.gitconfig` are +evaluated first, then those from the config file, under the same "later +definitions win per variable" rule as above. So a config-file rule overrides a +git config rule wherever both cover the same path, while a git config rule for +an unrelated tree keeps working. + +#### Where rules may not come from + +Not from a repository's committed `.wt.toml`, and not from `--local` git config. +A repository you clone must not be able to redirect where your worktrees land — +the same reason `root`, `repo_root` and `repo_pattern` are excluded from +`.wt.toml`. `--local` is also the wrong shape for the job: a rule scoped to one +repository is redundant, since that repository could set `wt.pattern` directly. + +The system scope (`git config --system`) is not read either — `wt` reads no +system git config at all, for any setting. #### Without `wt` configuration: direnv