diff --git a/go.mod b/go.mod index 0a80a6ce..adf6d071 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.3.0 // indirect + golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index 5363d9f9..877194e8 100644 --- a/go.sum +++ b/go.sum @@ -295,6 +295,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde h1:ejfdSekXMDxDLbRrJMwUk6KnSLZ2McaUCVcIKM+N6jc= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/config/config.go b/internal/config/config.go index 9ad25fec..f0806071 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( type Config struct { Colors bool `mapstructure:"colors"` Extends []string `mapstructure:"extends"` + Remotes []Remote `mapstructure:"remotes"` MinVersion string `mapstructure:"min_version"` SkipOutput []string `mapstructure:"skip_output"` SourceDir string `mapstructure:"source_dir"` @@ -15,6 +16,12 @@ type Config struct { Hooks map[string]*Hook } +type Remote struct { + URL string `mapstructure:"url"` + Rev string `mapstructure:"rev"` + Configs []string `mapstructure:"configs"` +} + func (c *Config) Validate() error { return version.CheckCovered(c.MinVersion) } diff --git a/internal/config/load.go b/internal/config/load.go index 560a1c0b..089878e7 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -3,10 +3,15 @@ package config import ( "path/filepath" "regexp" + "sort" "strings" + "sync" + "github.com/evilmartians/lefthook/internal/git" + "github.com/evilmartians/lefthook/internal/log" "github.com/spf13/afero" "github.com/spf13/viper" + "golang.org/x/sync/errgroup" ) const ( @@ -24,7 +29,7 @@ func Load(fs afero.Fs, path string) (*Config, error) { return nil, err } - extends, err := mergeAllExtends(fs, path) + extends, err := mergeAll(fs, path) if err != nil { return nil, err } @@ -61,13 +66,17 @@ func read(fs afero.Fs, path string, name string) (*viper.Viper, error) { return v, nil } -// Merges extends from .lefthook and .lefthook-local. -func mergeAllExtends(fs afero.Fs, path string) (*viper.Viper, error) { +// mergeAll merges remotes and extends from .lefthook and .lefthook-local. +func mergeAll(fs afero.Fs, path string) (*viper.Viper, error) { extends, err := read(fs, path, "lefthook") if err != nil { return nil, err } + if err := remotes(fs, extends); err != nil { + return nil, err + } + if err := extend(fs, extends); err != nil { return nil, err } @@ -79,6 +88,10 @@ func mergeAllExtends(fs afero.Fs, path string) (*viper.Viper, error) { } } + if err := remotes(fs, extends); err != nil { + return nil, err + } + if err := extend(fs, extends); err != nil { return nil, err } @@ -86,19 +99,88 @@ func mergeAllExtends(fs afero.Fs, path string) (*viper.Viper, error) { return extends, nil } -func extend(fs afero.Fs, v *viper.Viper) error { - for _, path := range v.GetStringSlice("extends") { - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) +func remotes(fs afero.Fs, v *viper.Viper) error { + var remotes []Remote + err := v.UnmarshalKey("remotes", &remotes) + if err != nil { + return err + } - another, err := read(fs, filepath.Dir(path), name) - if err != nil { + if len(remotes) == 0 { + return nil + } + + var ( + wg sync.WaitGroup + eg errgroup.Group + configPaths []string + configPathCh = make(chan string) + ) + + wg.Add(1) + go func() { + for configPath := range configPathCh { + configPaths = append(configPaths, configPath) + } + wg.Done() + }() + + for i := range remotes { + remote := remotes[i] + eg.Go(func() error { + dir, err := git.InitRemote(fs, remote.URL, remote.Rev) + if err != nil { + return err + } + + for _, path := range remote.Configs { + configPathCh <- filepath.Join(dir, path) + } + return nil + }) + } + + // Wait on errgroup to finish before closing the channel. + err = eg.Wait() + close(configPathCh) + if err != nil { + return err + } + + // Wait for all of the configPaths to be added. + wg.Wait() + + // Stable sort to ensure that the merge order is deterministic. + sort.SliceStable(configPaths, func(i, j int) bool { return configPaths[i] < configPaths[j] }) + + for _, configPath := range configPaths { + log.Debugf("Merging remote config: %v", configPath) + if err := merge(fs, configPath, v); err != nil { return err } - if err = v.MergeConfigMap(another.AllSettings()); err != nil { + } + return nil +} + +func extend(fs afero.Fs, v *viper.Viper) error { + for _, path := range v.GetStringSlice("extends") { + if err := merge(fs, path, v); err != nil { return err } } + return nil +} +func merge(fs afero.Fs, path string, v *viper.Viper) error { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + + another, err := read(fs, filepath.Dir(path), name) + if err != nil { + return err + } + if err = v.MergeConfigMap(another.AllSettings()); err != nil { + return err + } return nil } diff --git a/internal/git/remote.go b/internal/git/remote.go new file mode 100644 index 00000000..28dbd22b --- /dev/null +++ b/internal/git/remote.go @@ -0,0 +1,80 @@ +package git + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/evilmartians/lefthook/internal/log" + "github.com/spf13/afero" +) + +var defaultRemotePath = mustGetDefaultRemotesDir() + +// mustGetDefaultRemotesDir returns the default directory for the lefthook remotes. +func mustGetDefaultRemotesDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + panic(err) + } + return filepath.Join(homeDir, ".lefthook-remotes") +} + +// InitRemote clones or pulls the latest changes for a git repository that was specified as +// a remote config repository. If successful, the path to the root of the repository will be +// returned. +func InitRemote(fs afero.Fs, url, rev string) (string, error) { + err := fs.MkdirAll(defaultRemotePath, 0755) + if err != nil && !errors.Is(err, os.ErrExist) { + return "", err + } + + root := getRemoteDir(url) + + _, err = fs.Stat(root) + if err == nil { + if err := updateRemote(fs, root, url, rev); err != nil { + return "", err + } + return root, nil + } + + if err := cloneRemote(fs, root, url, rev); err != nil { + return "", err + } + return root, nil +} + +func updateRemote(fs afero.Fs, root, url, rev string) error { + log.Debugf("Updating remote config repository: %v", root) + cmdFetch := []string{"git", "-C", root, "pull", "-q"} + if len(rev) == 0 { + cmdFetch = append(cmdFetch, "origin", rev) + } + _, err := execGit(strings.Join(cmdFetch, " ")) + if err != nil { + return err + } + return nil +} + +func cloneRemote(fs afero.Fs, root, url, rev string) error { + log.Debugf("Cloning remote config repository: %v", root) + cmdClone := []string{"git", "-C", defaultRemotePath, "clone", "-q"} + if len(rev) > 0 { + cmdClone = append(cmdClone, "-b", rev) + } + cmdClone = append(cmdClone, url) + _, err := execGit(strings.Join(cmdClone, " ")) + if err != nil { + return err + } + return nil +} + +func getRemoteDir(url string) string { + // Removes any suffix that might have been used in the url like '.git'. + trimmedURL := strings.TrimSuffix(url, filepath.Ext(url)) + return filepath.Join(defaultRemotePath, filepath.Base(trimmedURL)) +}