mirror of https://github.com/databricks/cli.git
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:
parent
241562e2b1
commit
abb1de99ba
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -31,6 +32,19 @@ type Repository struct {
|
||||||
ignore map[string][]ignoreRules
|
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
|
// newIgnoreFile constructs a new [ignoreRules] implementation backed by
|
||||||
// a file using the specified path relative to the repository root.
|
// a file using the specified path relative to the repository root.
|
||||||
func (r *Repository) newIgnoreFile(relativeIgnoreFilePath string) ignoreRules {
|
func (r *Repository) newIgnoreFile(relativeIgnoreFilePath string) ignoreRules {
|
||||||
|
@ -120,11 +134,24 @@ func NewRepository(path string) (*Repository, error) {
|
||||||
ignore: make(map[string][]ignoreRules),
|
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.
|
// Initialize root ignore rules.
|
||||||
// These are special and not lazily initialized because:
|
// These are special and not lazily initialized because:
|
||||||
// 1) we include a hardcoded ignore pattern
|
// 1) we include a hardcoded ignore pattern
|
||||||
// 2) we include a gitignore file at a non-standard path
|
// 2) we include a gitignore file at a non-standard path
|
||||||
repo.ignore["."] = []ignoreRules{
|
repo.ignore["."] = []ignoreRules{
|
||||||
|
// Load global excludes on this machine.
|
||||||
|
newIgnoreFile(coreExcludesPath),
|
||||||
// Always ignore root .git directory.
|
// Always ignore root .git directory.
|
||||||
newStringIgnoreRules([]string{
|
newStringIgnoreRules([]string{
|
||||||
".git",
|
".git",
|
||||||
|
|
Loading…
Reference in New Issue