diff --git a/libs/git/config.go b/libs/git/config.go new file mode 100644 index 00000000..e83c75b7 --- /dev/null +++ b/libs/git/config.go @@ -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 +} diff --git a/libs/git/config_test.go b/libs/git/config_test.go new file mode 100644 index 00000000..3e6edf76 --- /dev/null +++ b/libs/git/config_test.go @@ -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) +} diff --git a/libs/git/repository.go b/libs/git/repository.go index 48887c8c..ba788f9b 100644 --- a/libs/git/repository.go +++ b/libs/git/repository.go @@ -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",