From 1dc496c41b9e266e9e167cba1b2eee254b0b3278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Sat, 10 Oct 2026 22:54:00 +0200 Subject: [PATCH 1/2] edge case in detecting the global config --- env.go | 7 +++++-- env_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/env.go b/env.go index 6e983f6..ea4b89e 100644 --- a/env.go +++ b/env.go @@ -81,7 +81,8 @@ type Env struct { // GlobalConfig lists the global configuration files, read in order. // Writes go to the last one that exists, or to the last one if none // exists: for git's [XDG file, ~/.gitconfig] this picks ~/.gitconfig - // unless only the XDG file exists. + // unless only the XDG file exists. A symbolic link exists if its target + // does. GlobalConfig []string // Command holds the values given on the command line (git -c, @@ -379,7 +380,9 @@ func (e Env) writeTarget(s Scope) (string, Scope, error) { return "", 0, ErrNoScope } for _, v := range slices.Backward(e.GlobalConfig) { - if _, err := os.Lstat(v); err == nil { + // A link to nothing does not exist, as for git: the write + // goes to the other file, not to the link's target. + if _, err := os.Stat(v); err == nil { return v, s, nil } } diff --git a/env_test.go b/env_test.go index 1a2eb4c..a1502fc 100644 --- a/env_test.go +++ b/env_test.go @@ -1111,6 +1111,32 @@ func TestEnvWritePath(t *testing.T) { } } +func TestEnvWritePathDanglingLink(t *testing.T) { + // A ~/.gitconfig that links to nothing does not exist: with an XDG + // file, git writes there; without, through the link (git 2.56). + dir := tempDir(t) + xdg := filepath.Join(dir, "xdg") + home := filepath.Join(dir, "home") + symlink(t, filepath.Join(dir, "dotfiles", "gitconfig"), home) + env := gitconfig.Env{GlobalConfig: []string{xdg, home}} + + got, err := env.WritePath(gitconfig.ScopeGlobal) + require.NoError(t, err) + assert.Equal(t, home, got) + + writeFile(t, xdg, "") + got, err = env.WritePath(gitconfig.ScopeGlobal) + require.NoError(t, err) + assert.Equal(t, xdg, got) + + require.NoError(t, env.UpdateScope(gitconfig.ScopeGlobal, func(f *gitconfig.File) error { + return f.Set("a.k", "v") + })) + _, err = os.Stat(filepath.Join(dir, "dotfiles")) + assert.ErrorIs(t, err, fs.ErrNotExist) + assert.Equal(t, "[a]\n\tk = v\n", readFile(t, xdg)) +} + // unsetenv unsets an environment variable for the duration of the test. func unsetenv(t *testing.T, key string) { t.Setenv(key, "") From 33538e657175c302c473de78d6f020ba335b83a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Sat, 10 Oct 2026 23:05:07 +0200 Subject: [PATCH 2/2] fix an obscur handling of wildcard --- docs/GIT-BEHAVIOUR.md | 1 + env_test.go | 6 ++++++ glob.go | 27 +++++++++++++++++++++------ interop_test.go | 7 +++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/GIT-BEHAVIOUR.md b/docs/GIT-BEHAVIOUR.md index a0c0a23..2ed8165 100644 --- a/docs/GIT-BEHAVIOUR.md +++ b/docs/GIT-BEHAVIOUR.md @@ -230,6 +230,7 @@ the same results. | `gitdir:$S/work/` | yes (trailing `/` means `/**`) | | `gitdir:$S/work` | no | | `gitdir:$S/work/**` | yes | +| `gitdir:$S/***`, `gitdir:$S/***/.git`, `gitdir:$S/work/****/proj/` | yes: a segment of three or more stars is `**`, matching any number of directories, zero included; mixed with other characters (`$S/***proj/`, `$S/w***/.git`) the stars are a single `*` *(2.47.3, 2.56.0)* | | `gitdir:$S/work/proj/.git` | yes | | `gitdir:work/` | yes (relative pattern gets an implicit `**/` prefix) | | `gitdir/i:/WORK/` | yes | diff --git a/env_test.go b/env_test.go index a1502fc..b38e633 100644 --- a/env_test.go +++ b/env_test.go @@ -615,6 +615,12 @@ func TestEnvIncludeIf(t *testing.T) { {"gitdir:" + s + "/work", false}, {"gitdir:" + s + "/work/**", true}, {"gitdir:" + s + "/w*/", true}, + {"gitdir:" + s + "/***", true}, + {"gitdir:" + s + "/***/.git", true}, + {"gitdir:" + s + "/work/****/proj/", true}, + {"gitdir:" + s + "/***/***/***/.git", true}, + {"gitdir:" + s + "/***proj/", false}, + {"gitdir:" + s + "/w***/.git", false}, {"gitdir:" + s + "/work/proj/.git", true}, {"gitdir:" + s + "/work/proj/.git/", false}, {"gitdir:" + s + "/work/proj", false}, diff --git a/glob.go b/glob.go index e91f6e5..333e883 100644 --- a/glob.go +++ b/glob.go @@ -5,7 +5,7 @@ import "strings" // The patterns of includeIf "gitdir:" and "onbranch:" are matched as paths: // '*' and '?' do not match '/', "**/" at the start of a segment matches any // number of directories, "**" ending the pattern after a '/' (or alone) -// matches anything, "[...]" is a class of characters ("[!...]" or "[^...]" +// matches anything, a segment of more stars ("***") is "**", "[...]" is a class of characters ("[!...]" or "[^...]" // negated, "a-z" a range, "[:digit:]" a named class) and '\' quotes the // next character. A pattern with an unterminated class, or an unknown class // name, matches nothing, as in git. @@ -42,18 +42,20 @@ func compileGlob(p string) ([]globToken, bool) { for i := 0; i < len(p); { c := p[i] switch { - case c == '*' && segStart && i+1 < len(p) && p[i+1] == '*' && (i+2 == len(p) || p[i+2] == '/'): - if i+2 == len(p) { + case c == '*' && segStart && starSegment(p[i:]) > 1: + // As in git, a segment of three or more stars is "**" too. + i += starSegment(p[i:]) + if i == len(p) { toks = append(toks, globToken{kind: globAll}) - i += 2 continue } if len(toks) == 0 || toks[len(toks)-1].kind != globDirs { // "**/**/" is "**/". toks = append(toks, globToken{kind: globDirs}) } - i += 3 - continue // still at the start of a segment + // Past the '/': still at the start of a segment. + i++ + continue case c == '*': for i < len(p) && p[i] == '*' { i++ @@ -85,6 +87,19 @@ func compileGlob(p string) ([]globToken, bool) { return toks, true } +// starSegment returns the number of stars p starts with, if they make up a +// whole segment (the end of p or a '/' follows), 0 otherwise. +func starSegment(p string) int { + n := 0 + for n < len(p) && p[n] == '*' { + n++ + } + if n < len(p) && p[n] != '/' { + return 0 + } + return n +} + // compileClass compiles the class starting at p[start], and returns the // index past its end, or false if it is not terminated or names an unknown // class. lastColon is the index of the last ":]" in p. diff --git a/interop_test.go b/interop_test.go index 47a426b..2638ff0 100644 --- a/interop_test.go +++ b/interop_test.go @@ -768,6 +768,13 @@ var gitScenarios = []gitScenario{ "gitdir:" + s + "/work", "gitdir:" + s + "/work/**", "gitdir:" + s + "/w*/", + "gitdir:" + s + "/***", + "gitdir:" + s + "/***/.git", + "gitdir:" + s + "/work/****/proj/", + "gitdir:" + s + "/***/***/***/.git", + "gitdir:" + s + "/***proj/", + "gitdir:" + s + "/w***/.git", + "onbranch:***", "gitdir:" + s + "/work/proj/.git", "gitdir:" + s + "/work/proj/.git/", "gitdir:work/",