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
4 changes: 3 additions & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ in [GIT-BEHAVIOUR.md](GIT-BEHAVIOUR.md).
| Relative `gitdir:./` from the command line | prints an error, goes on without the include | `ErrNotAbsolute` | no one to warn — *safe default* |
| Relative `include.path` from the command line | error | `ErrNotAbsolute` | 1 |
| Windows: `GIT_DIR` written with `\`, pattern with `/`, through a symbolic link | the path as given does not match | matches | looks like a git for Windows bug |
| `hasconfig:remote.*.url:` | supported | treated as false | not implemented yet (S8): the effective configuration can differ from git's |
| `hasconfig:remote.*.url:` | supported | the same | 1 |
| `hasconfig:remote.*.url:` met, remote URL in a file included by a `gitdir:` or `onbranch:` condition | fatal error, as for one included by `hasconfig:` itself | the same | 1, though it looks like a git bug |
| `hasconfig:remote.*.url:` met, implicit `remote.<name>.url` (no `=`) | segmentation fault | not a URL: matches nothing | git gives no answer |
| `onbranch:` with reftable | supported | treated as false | needs the ref store (S10) |

## Scopes and discovery
Expand Down
22 changes: 22 additions & 0 deletions docs/GIT-BEHAVIOUR.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ the same results.
| `git -c 'includeIf.gitdir:./.path=…'` | `error: relative config include conditionals must come from files`, then git goes on without the include, exit 0 *(2.47.3)* — this library fails with `ErrNotAbsolute`, having no one to warn |
| `gitdir:C:/x/link/`, `GIT_DIR=C:\x\link\proj\.git` with `link` a symbolic link to `work` (Windows) | **no**, while `gitdir:C:/x/work/` (the real path) matches *(2.55.0.windows.5)* — this library compares both paths with `/` and matches |

### `hasconfig:remote.*.url:` (remote `origin` at `https://example.com/a/b`)

Observed with git 2.43.0 and 2.56.0 (`alpine:edge`), with the same results.

| Condition or setup | Included? |
|---|---|
| `https://example.com/**`, `https://example.com/a/*`, `**/b`, `**`, `https://***/b`, `https://example.com/a/[a-c]` | yes |
| `https://example.com/*`, `https://example.com/` | no: `*` stops at `/`, a trailing `/` adds nothing |
| `https://EXAMPLE.com/**`, `example.com/**`, `*`, empty | no: case-sensitive, no implicit `**/` prefix |
| `hasconfig:remote.origin.url:**`, `hasconfig:remote.*.pushurl:**`, `hasconfig:user.name:**`, `HASCONFIG:remote.*.url:**`, `hasconfig:REMOTE.*.URL:**` | no: only this exact prefix is a condition |
| URL in the local file, condition in the global file | yes |
| URL from `-c`, `GIT_CONFIG_COUNT` | yes |
| URL further down the same file, or in a plain include | yes: every URL counts, read before or after |
| URL `[remote ""]` (empty name), `[Remote "o"] URL` | yes |
| `[remote] url` (no name), only a `pushurl` | no |
| `git config --global --includes`, `--local --includes`, `-f <file> --includes` | only the URLs of that scope or file count |
| URL in a file included by the condition, matching or not, or below it through `include.path` | `fatal: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url`, exit 128 |
| URL in a file included by a `gitdir:` or `onbranch:` condition that holds, with any `hasconfig:` condition met, even one with a missing file | the same fatal error; without a `hasconfig:` condition, or with the `gitdir:` condition false, it is read |
| `hasconfig:` condition only inside an include that is not followed | not met: no error |
| syntax error in the file of a `hasconfig:` condition that does not match | `fatal: bad config line 1`: the file is read |
| implicit `[remote "o"] url` (no `=`) with a `hasconfig:` condition met | segmentation fault (2.43.0 and 2.56.0) — this library takes it as no URL |

## Ownership and `safe.directory`

Observed with **git 2.56.0** (`alpine:edge`, Linux), as user `alice` (uid
Expand Down
2 changes: 1 addition & 1 deletion docs/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ from git, and the principles that decide it, are in
| S7 | `includeIf` conditions `gitdir:`, `gitdir/i:` (matched against the git directory as given and its real path, as git does; the git directory must be absolute; glob matching where `*` and `?` stop at `/` and `**/` spans directories, implicit `**/` prefix for relative patterns and `/**` suffix for a trailing `/`), `onbranch:`. The current branch is read from `HEAD` when needed. *verified* | MUST |
| S10 | `onbranch:` for repositories whose `HEAD` is not a plain file (reftable, in-memory storage), with a branch supplied by the caller. | LATER |
| S11 | Reading one scope whose file is absent (no system file, missing global or worktree file) gives an empty configuration, as `git config --get` does (`--list` fails there). Reading the local or worktree scope outside a repository fails, as in git. *verified* | MUST |
| S8 | `includeIf "hasconfig:remote.*.url:…"`, including git's rule that files it includes may not define remote URLs. | SHOULD |
| S8 | `includeIf "hasconfig:remote.*.url:…"`, including git's rule that files it includes may not define remote URLs. *verified* | SHOULD |
| S9 | Protected configuration: some keys (e.g. `safe.directory`) only honoured from system, global and command scopes. See X5. | MUST |

## 4. Writes and concurrency
Expand Down
24 changes: 16 additions & 8 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,22 @@ func Discover(dir string) (Env, error) {
// which scope it comes from. Missing files are skipped.
//
// Includes are followed as git does (include.path and includeIf with the
// gitdir:, gitdir/i: and onbranch: conditions; hasconfig: is never
// true), up to 10 levels deep (ErrIncludeDepth). Two safety bounds git lacks
// are added: an include must be a regular file (ErrNotRegular, so a FIFO or a
// device cannot hang or exhaust the process), and one load reads at most
// 1000 files, 4 MiB and 2¹⁶ entries and distinct section headers in total
// from files (ErrLoadLimit, so that a file including itself many times cannot
// multiply the work exponentially, and a file of tiny entries cannot exhaust
// memory).
// gitdir:, gitdir/i:, onbranch: and hasconfig:remote.*.url: conditions),
// up to 10 levels deep (ErrIncludeDepth). hasconfig:remote.*.url: matches
// the remote URLs of every scope the method reads (only that scope for
// LoadScope, that file for LoadFile); as in git, once such a condition is
// met, by any key of its section, a remote URL in a file included by any
// includeIf is an error (ErrRemoteURLInInclude), and the files of these
// conditions are read even when they do not match.
//
// Two safety bounds git lacks are added: an include must be a regular file
// (ErrNotRegular, so a FIFO or a device cannot hang or exhaust the
// process), and one load reads at most 1000 files, 4 MiB and 2¹⁶ entries
// and distinct section headers in total from files (ErrLoadLimit, so that a
// file including itself many times cannot multiply the work exponentially,
// and a file of tiny entries cannot exhaust memory). The files of
// hasconfig:remote.*.url: conditions count against these limits even when
// they do not match: they are read all the same.
// Real configurations never come near these bounds.
//
// As in git, includes are followed as a file is read: an error in an
Expand Down
208 changes: 208 additions & 0 deletions env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,138 @@ func TestEnvIncludeIf(t *testing.T) {
assert.False(t, included(t, env, "gitdir"))
})

t.Run("hasconfig remote URL", func(t *testing.T) {
// Checked against git 2.43 and 2.56: plain glob matching against
// each remote.<name>.url, of any scope, read before or after.
gitDir := filepath.Join(tempDir(t), ".git")
initRepo(t, gitDir, "[remote \"origin\"]\n\turl = https://example.com/a/b\n")
env := gitconfig.Env{GitDir: gitDir}
for _, tc := range []struct {
cond string
want bool
}{
{"hasconfig:remote.*.url:https://example.com/**", true},
{"hasconfig:remote.*.url:https://example.com/a/*", true},
{"hasconfig:remote.*.url:https://example.com/a/[a-c]", true},
{"hasconfig:remote.*.url:**/b", true},
{"hasconfig:remote.*.url:**", true},
{"hasconfig:remote.*.url:https://example.com/*", false},
{"hasconfig:remote.*.url:https://example.com/", false},
{"hasconfig:remote.*.url:https://EXAMPLE.com/**", false},
{"hasconfig:remote.*.url:example.com/**", false},
{"hasconfig:remote.*.url:*", false},
{"hasconfig:remote.*.url:", false},
{"hasconfig:remote.origin.url:**", false},
{"hasconfig:remote.*.pushurl:**", false},
{"hasconfig:REMOTE.*.URL:**", false},
{"HASCONFIG:remote.*.url:**", false},
} {
assert.Equal(t, tc.want, included(t, env, tc.cond), tc.cond)
}

outside := gitconfig.Env{Home: home}
assert.False(t, included(t, outside, "hasconfig:remote.*.url:**"))
outside.Command = []gitconfig.CommandEntry{{Key: "remote.o.url", Value: "https://x/y"}}
assert.True(t, included(t, outside, "hasconfig:remote.*.url:https://x/**"))
})

t.Run("hasconfig remote URL read later", func(t *testing.T) {
global := filepath.Join(tempDir(t), "global")
writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = %s/inc\n[a]\n\tk = after\n[remote \"\"]\n\turl = u\n", s))
c, err := gitconfig.Env{GlobalConfig: []string{global}}.Load()
require.NoError(t, err)
assert.Equal(t, []string{"inc", "after"}, values(c.GetAll("a.k")))
})

t.Run("hasconfig remote URL of the scope read", func(t *testing.T) {
gitDir := filepath.Join(tempDir(t), ".git")
initRepo(t, gitDir, "[remote \"origin\"]\n\turl = u\n")
global := filepath.Join(tempDir(t), "global")
writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:u\"]\n\tpath = %s/inc\n", s))
env := gitconfig.Env{GitDir: gitDir, GlobalConfig: []string{global}}
c, err := env.Load()
require.NoError(t, err)
_, ok := c.Get("a.k")
assert.True(t, ok, "Load")
c, err = env.LoadScope(gitconfig.ScopeGlobal)
require.NoError(t, err)
_, ok = c.Get("a.k")
assert.False(t, ok, "LoadScope: the local URL is not read")
c, err = env.LoadFile(global)
require.NoError(t, err)
_, ok = c.Get("a.k")
assert.False(t, ok, "LoadFile")
})

t.Run("hasconfig remote URL not matched drops nested includes", func(t *testing.T) {
dir := tempDir(t)
writeFile(t, filepath.Join(dir, "outer"), "[a]\n\tk = outer\n[include]\n\tpath = nested\n")
writeFile(t, filepath.Join(dir, "nested"), "[a]\n\tk = nested\n")
writeFile(t, filepath.Join(dir, "global"), "[a]\n\tk = before\n[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = outer\n[a]\n\tk = after\n")
c, err := gitconfig.Env{GlobalConfig: []string{filepath.Join(dir, "global")}}.Load()
require.NoError(t, err)
assert.Equal(t, []string{"before", "after"}, values(c.GetAll("a.k")))
})

t.Run("implicit remote URL", func(t *testing.T) {
// git 2.43 and 2.56 crash on it: it is no URL here.
global := filepath.Join(tempDir(t), "global")
writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = %s/inc\n[remote \"o\"]\n\turl\n", s))
c, err := gitconfig.Env{GlobalConfig: []string{global}}.Load()
require.NoError(t, err)
_, ok := c.Get("a.k")
assert.False(t, ok)
})

t.Run("remote URL in a file included by includeIf", func(t *testing.T) {
// git fails as soon as a hasconfig:remote.*.url: condition is met,
// even if it does not match: its file is read anyway, as are
// the files of the other conditions that hold.
dir := tempDir(t)
ds := filepath.ToSlash(dir)
gitDir := filepath.Join(dir, "repo", ".git")
initRepo(t, gitDir, "")
writeFile(t, filepath.Join(dir, "url"), "[a]\n\tk = v\n[Remote \"o\"]\n\tURL = https://x/y\n")
writeFile(t, filepath.Join(dir, "include-url"), "[include]\n\tpath = url\n")
writeFile(t, filepath.Join(dir, "bad"), "bad line\n")
for _, tc := range []struct {
name, global string
err string // the file at fault, "" for none
}{
{"in a hasconfig include", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = url\n", "url"},
{"in one that does not match", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = url\n", "url"},
{"below one", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = include-url\n", "url"},
{"in a gitdir include", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = missing\n[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", "url"},
{"in a gitdir include, no hasconfig", "[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", ""},
{"in a gitdir include that does not hold", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = missing\n[includeIf \"gitdir:/nope/\"]\n\tpath = url\n", ""},
{"in a plain include", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = missing\n[include]\n\tpath = url\n", ""},
{"syntax error in one that does not match", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = bad\n", "bad"},
// Any key meets the condition, before or after the URL.
{"condition with no path", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tfoo = bar\n[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", "url"},
{"condition with no path, after", "[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tfoo = bar\n", "url"},
// git fails at the URL, before reading on.
{"syntax error after", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = url\nbad line\n", "url"},
} {
t.Run(tc.name, func(t *testing.T) {
writeFile(t, filepath.Join(dir, "global"), tc.global)
_, err := gitconfig.Env{GitDir: gitDir, GlobalConfig: []string{filepath.Join(dir, "global")}}.Load()
if tc.err == "" {
require.NoError(t, err)
return
}
fe, ok := errors.AsType[*gitconfig.FileError](err)
require.True(t, ok, "%v", err)
assert.Equal(t, filepath.Join(dir, tc.err), fe.Path)
if tc.err == "url" {
assert.ErrorIs(t, err, gitconfig.ErrRemoteURLInInclude)
} else {
_, ok = errors.AsType[*gitconfig.SyntaxError](err)
assert.True(t, ok, "%v", err)
}
})
}
})

t.Run("relative gitdir pattern from the command line", func(t *testing.T) {
// git prints an error and goes on without the include.
env := gitconfig.Env{GitDir: gitDir, Command: []gitconfig.CommandEntry{{Key: "includeIf.gitdir:./.path", Value: s + "/inc"}}}
Expand Down Expand Up @@ -2233,3 +2365,79 @@ func BenchmarkEnv(b *testing.B) {
}
})
}

// FuzzLoad loads a global and a local file and three files they can
// include, from any bytes: "@0", "@1" and "@2" stand for the paths of the
// included files and "@R" for the directory holding them all, so that
// mutations reach includes and conditions. Every loaded entry must be an
// entry of the file it names, and a failure a *FileError.
func FuzzLoad(f *testing.F) {
root := tempDir(f)
home := filepath.Join(root, "home")
gitDir := filepath.Join(root, "work", ".git")
initRepo(f, gitDir, "")
files := []string{filepath.Join(home, ".gitconfig"), filepath.Join(gitDir, "config"),
filepath.Join(root, "i0"), filepath.Join(root, "i1"), filepath.Join(root, "i2")}
expand := strings.NewReplacer("@0", files[2], "@1", files[3], "@2", files[4], "@R", filepath.ToSlash(root))

f.Add("[includeIf \"hasconfig:remote.*.url:https://x/**\"]\n\tpath = @0\n", "[remote \"o\"]\n\turl = https://x/y\n", "[a]\n\tk = v\n", "", "")
f.Add("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = @0\n", "", "[remote \"o\"]\n\turl = u\n", "", "")
f.Add("[includeIf \"hasconfig:remote.*.url:*\"]\n\tpath = @0\n[includeIf \"gitdir:@R/\"]\n\tpath = @1\n", "[remote \"\"]\n\turl = u\n", "[include]\n\tpath = @2\n", "[remote \"o\"]\n\turl\n", "[a]\n\tk\n")
f.Add("[include]\n\tpath = @0\n", "[includeIf \"onbranch:main\"]\n\tpath = @1\n", "[include]\n\tpath = @0\n", "[includeIf \"gitdir/i:@R/**\"]\n\tpath = @2\n", "bad line\n")
f.Add("[includeIf \"hasconfig:remote.*.url:u\"]\n\tpath = @0\n[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = @1\n", "[remote \"a\"] url = u\n", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = @2\n", "[x]\n\ty = 1\n", "[z]\n\tw = 2\n")

f.Fuzz(func(t *testing.T, global, local, i0, i1, i2 string) {
parsed := make(map[string][]gitconfig.Entry)
for i, content := range []string{global, local, i0, i1, i2} {
data := expand.Replace(content)
writeFile(t, files[i], data)
if file, err := gitconfig.Parse(files[i], []byte(data)); err == nil {
parsed[files[i]] = slices.Collect(file.Config().All())
}
}
env := gitconfig.Env{Home: home, GlobalConfig: files[:1], GitDir: gitDir}

check := func(name string, c *gitconfig.Config, err error) []gitconfig.Entry {
t.Helper()
if err != nil {
fe, ok := errors.AsType[*gitconfig.FileError](err)
require.True(t, ok, "%s: %T %v", name, err, err)
// Any path an include names, such as a directory.
require.NotEmpty(t, fe.Path, name)
return nil
}
var entries []gitconfig.Entry
for e := range c.All() {
// Origins are not cleaned, as in git: "/@1" is "//<root>/i1".
own, ok := parsed[filepath.Clean(e.Origin.File)]
if !ok {
// An include outside the test files, such as /etc/gitconfig.
if data, err := os.ReadFile(e.Origin.File); err == nil {
if file, err := gitconfig.Parse(e.Origin.File, data); err == nil {
own, ok = slices.Collect(file.Config().All()), true
parsed[filepath.Clean(e.Origin.File)] = own
}
}
}
require.True(t, ok, "%s: entry from %q, unparsable or unknown", name, e.Origin.File)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
require.True(t, slices.ContainsFunc(own, func(p gitconfig.Entry) bool {
return p.Key == e.Key && p.Value == e.Value && p.Implicit == e.Implicit && p.Origin.Line == e.Origin.Line
}), "%s: %v is not in its file", name, e)
entries = append(entries, e)
}
return entries
}
c, err := env.Load()
first := check("Load", c, err)
c, err = env.Load()
require.Equal(t, first, check("Load again", c, err))
for _, s := range []gitconfig.Scope{gitconfig.ScopeGlobal, gitconfig.ScopeLocal} {
c, err := env.LoadScope(s)
check("LoadScope "+s.String(), c, err)
}
c, err = env.LoadProtected()
check("LoadProtected", c, err)
c, err = env.LoadFile(files[2])
check("LoadFile", c, err)
})
}
7 changes: 7 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ var (
// take a File beyond these limits fails with it.
ErrLoadLimit = errors.New("gitconfig: exceeded load limits")

// ErrRemoteURLInInclude is returned, as git refuses it, when a remote
// URL is read from a file included by an includeIf in a load that also
// meets an includeIf "hasconfig:remote.*.url:" condition: such a URL
// could change what the condition matches. The *FileError names the
// file of the URL.
ErrRemoteURLInInclude = errors.New("gitconfig: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url")

// ErrNotRegular is returned when a configuration file or an include is
// not a regular file (a directory, a FIFO, a device).
ErrNotRegular = errors.New("gitconfig: not a regular file")
Expand Down
13 changes: 9 additions & 4 deletions glob.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package gitconfig

import "strings"

// The patterns of includeIf "gitdir:" and "onbranch:" are matched as paths:
// The patterns of includeIf "gitdir:", "onbranch:" and
// "hasconfig:remote.*.url:" are matched as paths (URLs included, which get
// no implicit "**/" or trailing "**"):
// '*' and '?' do not match '/', "**/" at the start of a segment matches any
// number of directories, "**" ending the pattern after a '/' (or alone)
// matches anything, a segment of more stars ("***") is "**", "[...]" is a class of characters ("[!...]" or "[^...]"
Expand Down Expand Up @@ -189,9 +191,12 @@ func sameByte(a, b byte, fold bool) bool {
// ASCII letters is ignored.
func globMatch(pattern, name string, fold bool) bool {
toks, ok := compileGlob(pattern)
if !ok {
return false
}
return ok && matchTokens(toks, name, fold)
}

// matchTokens reports whether name matches the compiled pattern toks, so
// that a pattern matched against many names is compiled once.
func matchTokens(toks []globToken, name string, fold bool) bool {
n := int32(len(toks))
// A state is a position in the pattern, 2*s for token s, or 2*s+1 for
// the inside of the directories matched by the globDirs token s. Only
Expand Down
Loading
Loading