Locate and use global excludes file (#191)

This implements rudimentary gitconfig loading as specified at
https://git-scm.com/docs/git-config.
This commit is contained in:
Pieter Noordhuis 2023-02-02 12:25:53 +01:00 committed by GitHub
parent 241562e2b1
commit abb1de99ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 377 additions and 0 deletions

159
libs/git/config.go Normal file
View File

@ -0,0 +1,159 @@
package git
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/ini.v1"
)
// Config holds the entries of a gitconfig file.
//
// As map key we join the section name, optionally the subsection name,
// and the variable name with dots. The result is ~equivalent to the
// output of `git config --global --list`.
//
// While this doesn't capture the full richness of gitconfig it's good
// enough for the basic properties we care about (e.g. under `core`).
//
// Also see: https://git-scm.com/docs/git-config.
type config struct {
home string
variables map[string]string
}
func newConfig() (*config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return &config{
home: home,
variables: make(map[string]string),
}, nil
}
var regexpSection = regexp.MustCompile(`^([\w\-\.]+)(\s+"(.*)")?$`)
func (c config) walkSection(prefix []string, section *ini.Section) {
// Detect and split section name that includes subsection name.
if match := regexpSection.FindStringSubmatch(section.Name()); match != nil {
prefix = append(prefix, match[1])
if match[3] != "" {
prefix = append(prefix, match[3])
}
} else {
prefix = append(prefix, section.Name())
}
// Add variables in this section.
for key, value := range section.KeysHash() {
key = strings.ToLower(key)
if value == "" {
value = "true"
}
// Expand ~/ to home directory.
if strings.HasPrefix(value, "~/") {
value = filepath.Join(c.home, value[2:])
}
c.variables[strings.Join(append(prefix, key), ".")] = value
}
// Recurse into child sections.
c.walkSections(prefix, section.ChildSections())
}
func (c config) walkSections(prefix []string, sections []*ini.Section) {
for _, section := range sections {
c.walkSection(prefix, section)
}
}
func (c config) load(r io.Reader) error {
iniFile, err := ini.InsensitiveLoad(r)
if err != nil {
return err
}
// Collapse sections, subsections, and keys, into a flat namespace.
c.walkSections([]string{}, iniFile.Sections())
return nil
}
func (c config) loadFile(path string) error {
f, err := os.Open(path)
if err != nil {
// If the file doesn't exist it is ignored.
// This is the case for both global and repository specific config files.
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
err = c.load(f)
if err != nil {
return fmt.Errorf("failed to load %s: %w", path, err)
}
return nil
}
func (c config) defaultCoreExcludesFile() string {
// Defaults to $XDG_CONFIG_HOME/git/ignore.
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome == "" {
// If $XDG_CONFIG_HOME is either not set or empty,
// $HOME/.config/git/ignore is used instead.
xdgConfigHome = filepath.Join(c.home, ".config")
}
return filepath.Join(xdgConfigHome, "git/ignore")
}
func (c config) coreExcludesFile() (string, error) {
path := c.variables["core.excludesfile"]
if path == "" {
path = c.defaultCoreExcludesFile()
}
// Only return if this file is stat-able or doesn't exist (yet).
// If there are other problems accessing this file we would
// run into them at a later point anyway.
_, err := os.Stat(path)
if err != nil && !os.IsNotExist(err) {
return "", err
}
return path, nil
}
func globalGitConfig() (*config, error) {
config, err := newConfig()
if err != nil {
return nil, err
}
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome == "" {
xdgConfigHome = filepath.Join(config.home, ".config")
}
// From https://git-scm.com/docs/git-config#FILES:
//
// > If the global or the system-wide configuration files
// > are missing or unreadable they will be ignored.
//
// We therefore ignore the error return value for the calls below.
config.loadFile(filepath.Join(xdgConfigHome, "git/config"))
config.loadFile(filepath.Join(config.home, ".gitconfig"))
return config, nil
}

191
libs/git/config_test.go Normal file
View File

@ -0,0 +1,191 @@
package git
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfig(t *testing.T) {
// Taken from https://git-scm.com/docs/git-config#_example.
raw := `
# Core variables
[core]
; Don't trust file modes
filemode = false
# Our diff algorithm
[diff]
external = /usr/local/bin/diff-wrapper
renames = true
[branch "devel"]
remote = origin
merge = refs/heads/devel
# Proxy settings
[core]
gitProxy="ssh" for "kernel.org"
gitProxy=default-proxy ; for the rest
[include]
path = /path/to/foo.inc ; include by absolute path
path = foo.inc ; find "foo.inc" relative to the current file
path = ~/foo.inc ; find "foo.inc" in your $HOME directory
; include if $GIT_DIR is /path/to/foo/.git
[includeIf "gitdir:/path/to/foo/.git"]
path = /path/to/foo.inc
; include for all repositories inside /path/to/group
[includeIf "gitdir:/path/to/group/"]
path = /path/to/foo.inc
; include for all repositories inside $HOME/to/group
[includeIf "gitdir:~/to/group/"]
path = /path/to/foo.inc
; relative paths are always relative to the including
; file (if the condition is true); their location is not
; affected by the condition
[includeIf "gitdir:/path/to/group/"]
path = foo.inc
; include only if we are in a worktree where foo-branch is
; currently checked out
[includeIf "onbranch:foo-branch"]
path = foo.inc
; include only if a remote with the given URL exists (note
; that such a URL may be provided later in a file or in a
; file read after this file is read, as seen in this example)
[includeIf "hasconfig:remote.*.url:https://example.com/**"]
path = foo.inc
[remote "origin"]
url = https://example.com/git
`
c, err := newConfig()
require.NoError(t, err)
err = c.load(bytes.NewBufferString(raw))
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
assert.Equal(t, "false", c.variables["core.filemode"])
assert.Equal(t, "origin", c.variables["branch.devel.remote"])
// Verify that ~/ expands to the user's home directory.
assert.Equal(t, filepath.Join(home, "foo.inc"), c.variables["include.path"])
}
func TestCoreExcludesFile(t *testing.T) {
config, err := globalGitConfig()
require.NoError(t, err)
path, err := config.coreExcludesFile()
require.NoError(t, err)
t.Log(path)
}
type testCoreExcludesHelper struct {
*testing.T
home string
xdgConfigHome string
}
func (h *testCoreExcludesHelper) initialize(t *testing.T) {
h.T = t
// Create temporary $HOME directory.
h.home = t.TempDir()
t.Setenv("HOME", h.home)
t.Setenv("USERPROFILE", h.home)
// Create temporary $XDG_CONFIG_HOME directory.
h.xdgConfigHome = t.TempDir()
t.Setenv("XDG_CONFIG_HOME", h.xdgConfigHome)
xdgConfigHomeGit := filepath.Join(h.xdgConfigHome, "git")
err := os.MkdirAll(xdgConfigHomeGit, 0755)
require.NoError(t, err)
}
func (h *testCoreExcludesHelper) coreExcludesFile() (string, error) {
config, err := globalGitConfig()
require.NoError(h.T, err)
return config.coreExcludesFile()
}
func (h *testCoreExcludesHelper) writeConfig(path, contents string) {
err := os.WriteFile(path, []byte(contents), 0644)
require.NoError(h, err)
}
func TestCoreExcludesFileDefaultWithXdgConfigHome(t *testing.T) {
h := &testCoreExcludesHelper{}
h.initialize(t)
path, err := h.coreExcludesFile()
require.NoError(t, err)
assert.Equal(t, filepath.Join(h.xdgConfigHome, "git/ignore"), path)
}
func TestCoreExcludesFileDefaultWithoutXdgConfigHome(t *testing.T) {
h := &testCoreExcludesHelper{}
h.initialize(t)
h.Setenv("XDG_CONFIG_HOME", "")
path, err := h.coreExcludesFile()
require.NoError(t, err)
assert.Equal(t, filepath.Join(h.home, ".config/git/ignore"), path)
}
func TestCoreExcludesFileSetInXdgConfigHomeGitConfig(t *testing.T) {
h := &testCoreExcludesHelper{}
h.initialize(t)
h.writeConfig(filepath.Join(h.xdgConfigHome, "git/config"), `
[core]
excludesFile = ~/foo
`)
path, err := h.coreExcludesFile()
require.NoError(t, err)
assert.Equal(t, filepath.Join(h.home, "foo"), path)
}
func TestCoreExcludesFileSetInHomeGitConfig(t *testing.T) {
h := &testCoreExcludesHelper{}
h.initialize(t)
h.writeConfig(filepath.Join(h.home, ".gitconfig"), `
[core]
excludesFile = ~/foo
`)
path, err := h.coreExcludesFile()
require.NoError(t, err)
assert.Equal(t, filepath.Join(h.home, "foo"), path)
}
func TestCoreExcludesFileSetInBoth(t *testing.T) {
h := &testCoreExcludesHelper{}
h.initialize(t)
h.writeConfig(filepath.Join(h.xdgConfigHome, ".gitconfig"), `
[core]
excludesFile = ~/foo1
`)
h.writeConfig(filepath.Join(h.home, ".gitconfig"), `
[core]
excludesFile = ~/foo2
`)
path, err := h.coreExcludesFile()
require.NoError(t, err)
assert.Equal(t, filepath.Join(h.home, "foo2"), path)
}

View File

@ -1,6 +1,7 @@
package git
import (
"fmt"
"os"
"path"
"path/filepath"
@ -31,6 +32,19 @@ type Repository struct {
ignore map[string][]ignoreRules
}
// loadConfig loads and combines user specific and repository specific configuration files.
func (r *Repository) loadConfig() (*config, error) {
config, err := globalGitConfig()
if err != nil {
return nil, fmt.Errorf("unable to load user specific gitconfig: %w", err)
}
err = config.loadFile(filepath.Join(r.rootPath, ".git/config"))
if err != nil {
return nil, fmt.Errorf("unable to load repository specific gitconfig: %w", err)
}
return config, nil
}
// newIgnoreFile constructs a new [ignoreRules] implementation backed by
// a file using the specified path relative to the repository root.
func (r *Repository) newIgnoreFile(relativeIgnoreFilePath string) ignoreRules {
@ -120,11 +134,24 @@ func NewRepository(path string) (*Repository, error) {
ignore: make(map[string][]ignoreRules),
}
config, err := repo.loadConfig()
if err != nil {
// Error doesn't need to be rewrapped.
return nil, err
}
coreExcludesPath, err := config.coreExcludesFile()
if err != nil {
return nil, fmt.Errorf("unable to access core excludes file: %w", err)
}
// Initialize root ignore rules.
// These are special and not lazily initialized because:
// 1) we include a hardcoded ignore pattern
// 2) we include a gitignore file at a non-standard path
repo.ignore["."] = []ignoreRules{
// Load global excludes on this machine.
newIgnoreFile(coreExcludesPath),
// Always ignore root .git directory.
newStringIgnoreRules([]string{
".git",