Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 62 additions & 20 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -161,22 +172,36 @@ 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
}
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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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...)
}
}

Expand Down Expand Up @@ -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 != "" {
Expand Down
28 changes: 19 additions & 9 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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"])
Expand Down
100 changes: 95 additions & 5 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.<field>` parses as section wt, subsection context.<name>,
// variable <field>. 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
Expand Down
Loading