├── examples ├── sync-config │ ├── config.yaml │ ├── shared │ │ ├── config.yaml │ │ ├── new.yaml │ │ └── .goplicate.yaml │ └── .goplicate.yaml ├── sync-initial │ ├── shared │ │ └── config.yaml │ └── .goplicate.yaml ├── simple │ ├── shared-configs-repo │ │ ├── params.yaml │ │ └── .eslintrc.js │ ├── repo-1 │ │ ├── .goplicate.yaml │ │ └── .eslintrc.js │ └── repo-2 │ │ ├── .goplicate.yaml │ │ └── .eslintrc.js ├── projects-simple │ └── .goplicate-projects.yaml ├── projects-simple-remote-git │ └── .goplicate-projects.yaml └── simple-remote-git │ └── repo-1 │ ├── .goplicate.yaml │ └── .eslintrc.js ├── assets ├── logo.png └── goplicate-run.gif ├── .gitignore ├── pkg ├── config │ ├── hooks.go │ ├── target.go │ ├── project_config.go │ ├── projects_config.go │ └── source.go ├── utils │ ├── string.go │ ├── os.go │ ├── cmd.go │ ├── command.go │ ├── file.go │ └── interactive.go ├── shared │ └── shared_state.go ├── mocks │ └── cloner_mock.go ├── testdata │ └── blocks │ │ └── valid.yaml ├── hook.go ├── run_test.go ├── cmd │ ├── testutils │ │ └── test_utils.go │ ├── root.go │ ├── run_test.go │ ├── sync_test.go │ ├── run.go │ ├── flags.go │ └── sync.go ├── source.go ├── target_test.go ├── diff.go ├── git │ ├── cloner.go │ └── publisher.go ├── target.go ├── run.go ├── blocks_test.go └── blocks.go ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── cmd └── goplicate │ └── main.go ├── .golangci.yaml ├── Makefile ├── .goreleaser.yaml ├── LICENSE ├── go.mod ├── CODE_OF_CONDUCT.md ├── README.md ├── CONTRIBUTING.md └── go.sum /examples/sync-config/config.yaml: -------------------------------------------------------------------------------- 1 | oldKey: oldValue 2 | -------------------------------------------------------------------------------- /examples/sync-config/shared/config.yaml: -------------------------------------------------------------------------------- 1 | key: value 2 | -------------------------------------------------------------------------------- /examples/sync-config/shared/new.yaml: -------------------------------------------------------------------------------- 1 | newKey: newValue 2 | -------------------------------------------------------------------------------- /examples/sync-initial/shared/config.yaml: -------------------------------------------------------------------------------- 1 | key: value 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilaif/goplicate/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/goplicate-run.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilaif/goplicate/HEAD/assets/goplicate-run.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .vscode/** 4 | !.vscode/settings.json 5 | 6 | /goplicate 7 | 8 | /tmp/ 9 | 10 | dist/ 11 | -------------------------------------------------------------------------------- /examples/simple/shared-configs-repo/params.yaml: -------------------------------------------------------------------------------- 1 | # A params file that can be used for keeping the 2 | # configuration even DRY-er. 3 | 4 | indent: 2 5 | -------------------------------------------------------------------------------- /pkg/config/hooks.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Hooks a list of commands to execute after the templating is done. 4 | type Hooks struct { 5 | Post []string `yaml:"post"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func CountLeadingSpaces(line string) int { 8 | return len(line) - len(strings.TrimLeft(line, " ")) 9 | } 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/dnephin/pre-commit-golang 3 | rev: v0.5.0 4 | hooks: 5 | - id: go-mod-tidy 6 | - id: golangci-lint 7 | - id: go-unit-tests 8 | -------------------------------------------------------------------------------- /pkg/shared/shared_state.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | // State a shared state struct to pass state during goplicate sync 4 | // between different project runs. 5 | type State struct { 6 | Message string // Message the message for the change request 7 | } 8 | -------------------------------------------------------------------------------- /examples/projects-simple/.goplicate-projects.yaml: -------------------------------------------------------------------------------- 1 | # .goplicate-projects.yaml is used by the "goplicate sync" 2 | # command to run on multiple projects with a single command. 3 | 4 | projects: 5 | - location: 6 | path: ../simple/repo-1 7 | - location: 8 | path: ../simple/repo-2 9 | -------------------------------------------------------------------------------- /examples/projects-simple-remote-git/.goplicate-projects.yaml: -------------------------------------------------------------------------------- 1 | # .goplicate-projects.yaml is used by the "goplicate sync" 2 | # command to run on multiple projects with a single command. 3 | 4 | projects: 5 | - location: 6 | repository: https://github.com/ilaif/goplicate-example-repo-1 7 | clone-path: cloned/repo-1 8 | -------------------------------------------------------------------------------- /examples/sync-initial/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | - path: config.yaml 7 | source: 8 | path: ./shared/config.yaml 9 | sync-initial: true 10 | -------------------------------------------------------------------------------- /examples/simple/repo-1/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | - path: .eslintrc.js 7 | source: 8 | path: ../shared-configs-repo/.eslintrc.js 9 | params: 10 | - path: ../shared-configs-repo/params.yaml 11 | -------------------------------------------------------------------------------- /examples/simple/repo-2/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | - path: .eslintrc.js 7 | source: 8 | path: ../shared-configs-repo/.eslintrc.js 9 | params: 10 | - path: ../shared-configs-repo/params.yaml 11 | -------------------------------------------------------------------------------- /pkg/utils/os.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | func MustGetwd() string { 10 | wd, err := os.Getwd() 11 | if err != nil { 12 | panic(err) 13 | } 14 | 15 | return wd 16 | } 17 | 18 | func Chdir(dir string) error { 19 | if err := os.Chdir(dir); err != nil { 20 | return errors.Wrap(err, "Failed to change directory") 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/mocks/cloner_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ilaif/goplicate/pkg/git" 7 | ) 8 | 9 | type ClonerMock struct { 10 | } 11 | 12 | func (c *ClonerMock) Clone( 13 | ctx context.Context, 14 | uri, branch, fixedClonePath string, 15 | ) (clonePath string, err error) { 16 | return "", nil 17 | } 18 | 19 | func (c *ClonerMock) Close() {} 20 | 21 | var _ git.Cloner = &ClonerMock{} 22 | -------------------------------------------------------------------------------- /examples/sync-config/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | # goplicate-start:common 7 | - path: config.yaml 8 | source: 9 | path: ./shared/config.yaml 10 | # goplicate-end:common 11 | sync-config: 12 | path: .goplicate.yaml 13 | source: 14 | path: ./shared/.goplicate.yaml 15 | -------------------------------------------------------------------------------- /examples/sync-config/shared/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | # goplicate-start:common 7 | - path: config.yaml 8 | source: 9 | path: ./shared/config.yaml 10 | - path: new.yaml 11 | source: 12 | path: ./shared/new.yaml 13 | sync-initial: true 14 | # goplicate-end:common 15 | -------------------------------------------------------------------------------- /examples/simple-remote-git/repo-1/.goplicate.yaml: -------------------------------------------------------------------------------- 1 | # A .goplicate.yaml configuration file that tells goplicate 2 | # which "target" files to sync, where to take the "source" 3 | # configurations from, and how to fill parameter values. 4 | 5 | targets: 6 | - path: .eslintrc.js 7 | source: 8 | repository: https://github.com/ilaif/goplicate-example-shared-configs 9 | path: .eslintrc.js 10 | params: 11 | - repository: https://github.com/ilaif/goplicate-example-shared-configs 12 | path: params.yaml 13 | -------------------------------------------------------------------------------- /pkg/utils/cmd.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/caarlos0/log" 7 | ) 8 | 9 | func ChWorkdir(args []string) (string, func(), error) { 10 | workdir := "." 11 | if len(args) > 0 { 12 | workdir = args[0] 13 | } 14 | 15 | origWorkdir := MustGetwd() 16 | if err := Chdir(workdir); err != nil { 17 | return workdir, nil, err 18 | } 19 | 20 | return workdir, func() { 21 | log.Debugf("Cleanup: Restoring original working directory '%s'", origWorkdir) 22 | _ = os.Chdir(origWorkdir) 23 | }, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/testdata/blocks/valid.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # goplicate(name=common,pos=start) 3 | - name: common 4 | hooks: 5 | - id: my-common-pre-commit-hook 6 | # goplicate(name=common,pos=end) 7 | - name: local 8 | hooks: 9 | - id: my-project-1-pre-commit-hook 10 | # goplicate_start(name=external) 11 | - name: external 12 | hooks: 13 | - id: my-external-pre-commit-hook 14 | # goplicate_end(name=external) 15 | # goplicate-start:new 16 | - name: new 17 | hooks: 18 | - id: my-new-pre-commit-hook 19 | # goplicate-end:new 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: ["v*"] 5 | env: 6 | GO_VERSION: 1.18 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: ${{ env.GO_VERSION }} 17 | - uses: goreleaser/goreleaser-action@v3 18 | if: success() && startsWith(github.ref, 'refs/tags/') 19 | with: 20 | version: latest 21 | args: release --rm-dist 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 24 | -------------------------------------------------------------------------------- /cmd/goplicate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "runtime/debug" 7 | 8 | "github.com/ilaif/goplicate/pkg/cmd" 9 | ) 10 | 11 | var ( 12 | version = "dev" 13 | ) 14 | 15 | func main() { 16 | cmd.Execute(buildVersion(version)) 17 | } 18 | 19 | func buildVersion(version string) string { 20 | result := version 21 | result = fmt.Sprintf("%s\ngoos: %s\ngoarch: %s", result, runtime.GOOS, runtime.GOARCH) 22 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 23 | result = fmt.Sprintf("%s\nmodule version: %s, checksum: %s", result, info.Main.Version, info.Main.Sum) 24 | } 25 | 26 | return result 27 | } 28 | -------------------------------------------------------------------------------- /examples/simple/shared-configs-repo/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // A sample .eslintrc.js "source" (shared) configuration, 2 | // where only the sections that are surrounded by 3 | // goplicate-start/end comments are synced. 4 | // 5 | // Note the '{{.indent}}' template variable, which will be 6 | // replaced with the value from params.yaml when running goplicate. 7 | 8 | module.exports = { 9 | rules: { 10 | // goplicate-start:common-rules 11 | // enable additional rules 12 | indent: ['error', {{.indent}}], 13 | 'linebreak-style': ['error', 'unix'], 14 | quotes: ['error', 'double'], 15 | semi: ['error', 'always'], 16 | // goplicate-end:common-rules 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /examples/simple/repo-1/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // A sample .eslintrc.js "target" configuration, 2 | // where only the sections that are surrounded by 3 | // goplicate-start/end comments are synced from the shared configuration. 4 | 5 | module.exports = { 6 | extends: 'eslint:recommended', 7 | rules: { 8 | // goplicate-start:common-rules 9 | // enable additional rules 10 | indent: ['error', 4], 11 | 'linebreak-style': ['error', 'unix'], 12 | quotes: ['error', 'double'], 13 | semi: ['error', 'always'], 14 | // goplicate-end:common-rules 15 | 16 | // override configuration set by extending "eslint:recommended" 17 | 'no-empty': 'warn', 18 | 'no-cond-assign': ['error', 'always'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /examples/simple/repo-2/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // A sample .eslintrc.js "target" configuration, 2 | // where only the sections that are surrounded by 3 | // goplicate-start/end comments are synced from the shared configuration. 4 | 5 | module.exports = { 6 | extends: 'eslint:recommended', 7 | rules: { 8 | // goplicate-start:common-rules 9 | // enable additional rules 10 | indent: ['error', 4], 11 | 'linebreak-style': ['error', 'unix'], 12 | quotes: ['error', 'double'], 13 | semi: ['error', 'always'], 14 | // goplicate-end:common-rules 15 | 16 | // override configuration set by extending "eslint:recommended" 17 | 'no-empty': 'warn', 18 | 'no-cond-assign': ['error', 'always'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /examples/simple-remote-git/repo-1/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // A sample .eslintrc.js "target" configuration, 2 | // where only the sections that are surrounded by 3 | // goplicate-start/end comments are synced from the shared configuration. 4 | 5 | module.exports = { 6 | extends: 'eslint:recommended', 7 | rules: { 8 | // goplicate-start:common-rules 9 | // enable additional rules 10 | indent: ['error', 4], 11 | 'linebreak-style': ['error', 'unix'], 12 | quotes: ['error', 'double'], 13 | semi: ['error', 'always'], 14 | // goplicate-end:common-rules 15 | 16 | // override configuration set by extending "eslint:recommended" 17 | 'no-empty': 'warn', 18 | 'no-cond-assign': ['error', 'always'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /pkg/utils/command.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/caarlos0/log" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type CommandRunner struct { 13 | Dir string 14 | } 15 | 16 | func NewCommandRunner(dir string) *CommandRunner { 17 | return &CommandRunner{Dir: dir} 18 | } 19 | 20 | func (c *CommandRunner) Run(ctx context.Context, name string, args ...string) (string, error) { 21 | cmd := exec.CommandContext(ctx, name, args...) 22 | cmd.Dir = c.Dir 23 | 24 | log.Debugf("Running command '%s %s' in directory '%s'", name, strings.Join(args, " "), c.Dir) 25 | 26 | bytes, err := cmd.CombinedOutput() 27 | if err != nil { 28 | return string(bytes), errors.Wrap(err, "Failed to run command") 29 | } 30 | 31 | return string(bytes), err 32 | } 33 | -------------------------------------------------------------------------------- /pkg/hook.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/caarlos0/log" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func RunHook(ctx context.Context, hook string) error { 13 | log.Infof("Running post hook '%s'", hook) 14 | log.IncreasePadding() 15 | defer log.DecreasePadding() 16 | 17 | cmdParts := strings.Split(hook, " ") 18 | args := []string{} 19 | if len(cmdParts) > 0 { 20 | args = append(args, cmdParts[1:]...) 21 | } 22 | 23 | outBytes, err := exec.CommandContext(ctx, cmdParts[0], args...).CombinedOutput() // nolint:gosec 24 | out := string(outBytes) 25 | if err != nil { 26 | return errors.Wrapf(err, "Failed to run post hook '%s': %s", hook, out) 27 | } 28 | 29 | if out != "" { 30 | log.Infof("Output: %s", out) 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | # fast linters: 5 | - goimports 6 | - gocyclo 7 | - goconst 8 | - misspell 9 | - ineffassign 10 | - lll 11 | - gci 12 | - nlreturn 13 | - forbidigo 14 | - reassign 15 | # slow linters: 16 | - gosec 17 | - gosimple 18 | - govet 19 | - errcheck 20 | - gocritic 21 | - importas 22 | - revive 23 | - typecheck 24 | - unused 25 | - wrapcheck 26 | linters-settings: 27 | forbidigo: { forbid: ['fmt\.Errorf.*'] } 28 | gci: { sections: [standard, default, prefix(github.com/ilaif/goplicate)] } 29 | wrapcheck: { ignorePackageGlobs: ["github.com/ilaif/goplicate/**"] } 30 | issues: 31 | max-issues-per-linter: 0 32 | max-same-issues: 0 33 | run: 34 | timeout: 3m 35 | skip-dirs: [/tmp] 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | 3 | help: 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 5 | 6 | .DEFAULT_GOAL := help 7 | 8 | ide-setup: ## Installs specific requirements for local development 9 | curl -sSfL \ 10 | "https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" | \ 11 | sh -s -- -b $$(go env GOPATH)/bin v1.49.0 12 | go install gotest.tools/gotestsum@v1.8.2 13 | pre-commit install 14 | 15 | lint: ## Run lint 16 | golangci-lint run ./... 17 | 18 | test: ## Run unit tests 19 | go test ./... 20 | 21 | testwatch: ## Run unit tests in watch mode, re-running tests on each file change 22 | -gotestsum --format pkgname -- -short ./... 23 | gotestsum --watch --format pkgname -- -short ./... 24 | 25 | build: ## Build the binary 26 | go build ./cmd/goplicate 27 | -------------------------------------------------------------------------------- /pkg/run_test.go: -------------------------------------------------------------------------------- 1 | package pkg_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/ilaif/goplicate/pkg" 10 | "github.com/ilaif/goplicate/pkg/cmd/testutils" 11 | "github.com/ilaif/goplicate/pkg/mocks" 12 | "github.com/ilaif/goplicate/pkg/shared" 13 | ) 14 | 15 | func TestRun_Success_SyncConfig(t *testing.T) { 16 | r := require.New(t) 17 | 18 | defer testutils.PrepareWorkdir(t, "../examples/sync-config", ".")() 19 | 20 | cloner := &mocks.ClonerMock{} 21 | opts := pkg.NewRunOpts(false, true, false, false, false, false, "", "") 22 | 23 | sharedState := &shared.State{ 24 | Message: "", 25 | } 26 | 27 | r.NoError(pkg.Run(context.TODO(), cloner, sharedState, opts)) 28 | 29 | testutils.RequireFileContains(r, ".goplicate.yaml", "path: new.yaml") 30 | testutils.RequireFileContains(r, "new.yaml", "newKey: newValue") 31 | } 32 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - main: ./cmd/goplicate 6 | env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | goarch: 13 | - amd64 14 | - arm64 15 | ldflags: 16 | - -s -w 17 | - -X main.version={{.Version}} 18 | archives: 19 | - replacements: 20 | darwin: Darwin 21 | linux: Linux 22 | windows: Windows 23 | 386: i386 24 | amd64: x86_64 25 | checksum: 26 | name_template: "checksums.txt" 27 | snapshot: 28 | name_template: "{{ incpatch .Version }}-next" 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - "^docs:" 34 | - "^test:" 35 | brews: 36 | - name: goplicate 37 | homepage: https://github.com/ilaif/goplicate 38 | tap: 39 | owner: ilaif 40 | name: homebrew-tap 41 | release: 42 | prerelease: auto 43 | -------------------------------------------------------------------------------- /pkg/config/target.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | // Target defines a `path` to apply goplicate block snippets on based on the `source` with the supplied `params` 8 | type Target struct { 9 | Path string `yaml:"path"` 10 | Source Source `yaml:"source"` 11 | Params []Source `yaml:"params"` 12 | // SyncInitial whether to copy the whole file 13 | // from the source if it doesn't exist. 14 | SyncInitial bool `yaml:"sync-initial"` 15 | } 16 | 17 | func (t *Target) Validate() error { 18 | if t.Path == "" { 19 | return errors.New("'path' cannot be empty") 20 | } 21 | 22 | if err := t.Source.Validate(); err != nil { 23 | return errors.Wrap(err, "'source' is invalid") 24 | } 25 | 26 | for _, param := range t.Params { 27 | if err := param.Validate(); err != nil { 28 | return errors.Wrap(err, "A param is invalid") 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/testutils/test_utils.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | cp "github.com/otiai10/copy" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/ilaif/goplicate/pkg/utils" 12 | ) 13 | 14 | func PrepareWorkdir(t *testing.T, source string, cd string) func() { 15 | r := require.New(t) 16 | 17 | dir, err := os.MkdirTemp(os.TempDir(), "_goplicate_"+t.Name()) 18 | r.NoError(err) 19 | r.NoError(cp.Copy(source, dir)) 20 | origWd, err := os.Getwd() 21 | r.NoError(err) 22 | r.NoError(os.Chdir(path.Join(dir, cd))) 23 | 24 | return func() { 25 | os.RemoveAll(dir) 26 | _ = os.Chdir(origWd) 27 | } 28 | } 29 | 30 | func RequireFileContains(r *require.Assertions, filepath string, contains string) { 31 | bytes, err := os.ReadFile(path.Join(utils.MustGetwd(), filepath)) 32 | r.NoError(err) 33 | contents := string(bytes) 34 | r.Contains(contents, contains) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/source.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/caarlos0/log" 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ilaif/goplicate/pkg/config" 11 | "github.com/ilaif/goplicate/pkg/git" 12 | ) 13 | 14 | // ResolveSourcePath given a source, resolves it by cloning the repository (if applicable) 15 | // and returning the directory of the source. 16 | func ResolveSourcePath(ctx context.Context, source config.Source, workdir string, cloner git.Cloner) (string, error) { 17 | log.Debugf("Resolving path of source '%s'", source.String()) 18 | 19 | var err error 20 | 21 | branch := source.Branch 22 | if source.Tag != "" { 23 | branch = source.Tag 24 | } 25 | 26 | dir := workdir 27 | if source.Repository != "" { 28 | absClonePath := "" 29 | if source.ClonePath != "" { 30 | absClonePath = path.Join(workdir, source.ClonePath) 31 | } 32 | dir, err = cloner.Clone(ctx, string(source.Repository), branch, absClonePath) 33 | if err != nil { 34 | return "", errors.Wrap(err, "Failed to clone repository") 35 | } 36 | } 37 | 38 | return path.Join(dir, source.Path), nil 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ilai Fallach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/pkg/errors" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func ReadFile(filename string) ([]byte, error) { 13 | filename, err := filepath.Abs(filename) 14 | if err != nil { 15 | return nil, errors.Wrapf(err, "Failed to get absolute path for file '%s'", filename) 16 | } 17 | 18 | buf, err := os.ReadFile(filename) 19 | if err != nil { 20 | return nil, errors.Wrapf(err, "Failed to read file '%s'", filename) 21 | } 22 | 23 | return buf, nil 24 | } 25 | 26 | func WriteStringToFile(filename string, text string) error { 27 | if err := os.WriteFile(filename, []byte(text), fs.FileMode(os.O_WRONLY)); err != nil { 28 | return errors.Wrapf(err, "Failed to write to file '%s'", filename) 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func ReadYaml(filename string, cfg interface{}) error { 35 | buf, err := ReadFile(filename) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | err = yaml.Unmarshal(buf, cfg) 41 | if err != nil { 42 | return errors.Wrapf(err, "Failed to parse config from '%s'", filename) 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/caarlos0/log" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func Execute(version string) { 12 | rootCmd := NewRootCmd(version) 13 | ctx := context.Background() 14 | 15 | if err := rootCmd.ExecuteContext(ctx); err != nil { 16 | os.Exit(1) 17 | } 18 | } 19 | 20 | func NewRootCmd(version string) *cobra.Command { 21 | var ( 22 | debug bool 23 | ) 24 | 25 | var rootCmd = &cobra.Command{ 26 | Use: "goplicate", 27 | Short: "Sync project configuration snippets from a source repository to multiple target projects", 28 | SilenceUsage: true, 29 | Version: version, 30 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 31 | log.SetLevel(log.InfoLevel) 32 | log.DecreasePadding() // remove the default padding 33 | 34 | if debug { 35 | log.Info("Debug logs enabled") 36 | log.SetLevel(log.DebugLevel) 37 | } 38 | }, 39 | } 40 | 41 | rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "verbose logging") 42 | 43 | rootCmd.AddCommand( 44 | NewRunCmd(), 45 | NewSyncCmd(), 46 | ) 47 | 48 | return rootCmd 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cmd/run_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/ilaif/goplicate/pkg/cmd" 9 | "github.com/ilaif/goplicate/pkg/cmd/testutils" 10 | ) 11 | 12 | func TestRunCmd(t *testing.T) { 13 | r := require.New(t) 14 | 15 | defer testutils.PrepareWorkdir(t, "../../examples/simple", "repo-1")() 16 | 17 | testutils.RequireFileContains(r, ".eslintrc.js", "indent: ['error', 4]") 18 | 19 | runCmd := cmd.NewRunCmd() 20 | runCmd.SetArgs([]string{"--confirm"}) 21 | 22 | r.NoError(runCmd.Execute()) 23 | 24 | testutils.RequireFileContains(r, ".eslintrc.js", "indent: ['error', 2]") 25 | } 26 | 27 | func TestRunCmd_RemoteGit(t *testing.T) { 28 | if testing.Short() { 29 | t.Skip("Skipping test in short mode") 30 | } 31 | 32 | r := require.New(t) 33 | 34 | defer testutils.PrepareWorkdir(t, "../../examples/simple-remote-git", "repo-1")() 35 | 36 | testutils.RequireFileContains(r, ".eslintrc.js", "indent: ['error', 4]") 37 | 38 | runCmd := cmd.NewRunCmd() 39 | runCmd.SetArgs([]string{"--confirm"}) 40 | 41 | r.NoError(runCmd.Execute()) 42 | 43 | testutils.RequireFileContains(r, ".eslintrc.js", "indent: ['error', 2]") 44 | } 45 | -------------------------------------------------------------------------------- /pkg/config/project_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | 6 | "github.com/ilaif/goplicate/pkg/utils" 7 | ) 8 | 9 | const ( 10 | DefaultProjectConfigFilename = ".goplicate.yaml" 11 | ) 12 | 13 | func LoadProjectConfig() (*ProjectConfig, error) { 14 | cfg := &ProjectConfig{} 15 | if err := utils.ReadYaml(DefaultProjectConfigFilename, cfg); err != nil { 16 | return nil, errors.Wrap(err, "Failed to load project config") 17 | } 18 | 19 | if err := cfg.Validate(); err != nil { 20 | return nil, errors.Wrap(err, "Failed to validate project config") 21 | } 22 | 23 | return cfg, nil 24 | } 25 | 26 | type ProjectConfig struct { 27 | Targets []Target `yaml:"targets"` 28 | Hooks Hooks `yaml:"hooks"` 29 | SyncConfig *Target `yaml:"sync-config"` 30 | } 31 | 32 | func (pc *ProjectConfig) Validate() error { 33 | for _, target := range pc.Targets { 34 | if err := target.Validate(); err != nil { 35 | return errors.Wrap(err, "A target is invalid") 36 | } 37 | } 38 | 39 | if pc.SyncConfig != nil { 40 | if err := pc.SyncConfig.Validate(); err != nil { 41 | return errors.Wrap(err, "'config-sync' is invalid") 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | permissions: 5 | contents: read 6 | env: 7 | GO_VERSION: 1.18 8 | jobs: 9 | go-mod: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/setup-go@v3 13 | with: 14 | go-version: ${{ env.GO_VERSION }} 15 | - uses: actions/checkout@v3 16 | - name: Check go mod 17 | run: | 18 | go mod tidy 19 | git diff --exit-code go.mod 20 | lint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/setup-go@v3 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | - uses: actions/checkout@v3 27 | - name: golangci-lint 28 | uses: golangci/golangci-lint-action@v3 29 | with: 30 | version: v1.49.0 31 | test: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/setup-go@v3 35 | with: 36 | go-version: ${{ env.GO_VERSION }} 37 | - uses: actions/checkout@v3 38 | - uses: actions/cache@v3 39 | with: 40 | path: ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-go- 44 | - name: Test 45 | run: go test ./... 46 | -------------------------------------------------------------------------------- /pkg/target_test.go: -------------------------------------------------------------------------------- 1 | package pkg_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/ilaif/goplicate/pkg" 10 | "github.com/ilaif/goplicate/pkg/cmd/testutils" 11 | "github.com/ilaif/goplicate/pkg/config" 12 | "github.com/ilaif/goplicate/pkg/mocks" 13 | ) 14 | 15 | func TestRunTarget_Error_SyncingToNonExistentFile(t *testing.T) { 16 | r := require.New(t) 17 | 18 | defer testutils.PrepareWorkdir(t, "../examples/sync-initial", ".")() 19 | 20 | target := config.Target{ 21 | Path: "config.yaml", 22 | Source: config.Source{Path: "./shared/config.yaml"}, 23 | SyncInitial: false, 24 | } 25 | cloner := &mocks.ClonerMock{} 26 | 27 | _, err := pkg.RunTarget(context.TODO(), target, cloner, false, true) 28 | r.ErrorContains(err, "Failed to read file") 29 | } 30 | 31 | func TestRunTarget_Success_SyncingToNonExistentFile_WithSyncInitial(t *testing.T) { 32 | r := require.New(t) 33 | 34 | defer testutils.PrepareWorkdir(t, "../examples/sync-initial", ".")() 35 | 36 | target := config.Target{ 37 | Path: "config.yaml", 38 | Source: config.Source{Path: "./shared/config.yaml"}, 39 | SyncInitial: true, 40 | } 41 | cloner := &mocks.ClonerMock{} 42 | 43 | _, err := pkg.RunTarget(context.TODO(), target, cloner, false, true) 44 | r.NoError(err) 45 | 46 | testutils.RequireFileContains(r, "config.yaml", "key: value") 47 | } 48 | -------------------------------------------------------------------------------- /pkg/cmd/sync_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/ilaif/goplicate/pkg/cmd" 9 | "github.com/ilaif/goplicate/pkg/cmd/testutils" 10 | ) 11 | 12 | func TestSyncCmd_Simple(t *testing.T) { 13 | r := require.New(t) 14 | 15 | defer testutils.PrepareWorkdir(t, "../../examples", "projects-simple")() 16 | 17 | testutils.RequireFileContains(r, "../simple/repo-1/.eslintrc.js", "indent: ['error', 4]") 18 | testutils.RequireFileContains(r, "../simple/repo-2/.eslintrc.js", "indent: ['error', 4]") 19 | 20 | syncCmd := cmd.NewSyncCmd() 21 | syncCmd.SetArgs([]string{"--confirm"}) 22 | 23 | r.NoError(syncCmd.Execute()) 24 | 25 | testutils.RequireFileContains(r, "../simple/repo-1/.eslintrc.js", "indent: ['error', 2]") 26 | testutils.RequireFileContains(r, "../simple/repo-2/.eslintrc.js", "indent: ['error', 2]") 27 | } 28 | 29 | func TestSyncCmd_RemoteGit(t *testing.T) { 30 | if testing.Short() { 31 | t.Skip("Skipping test in short mode") 32 | } 33 | 34 | r := require.New(t) 35 | 36 | defer testutils.PrepareWorkdir(t, "../../examples", "projects-simple-remote-git")() 37 | 38 | rootCmd := cmd.NewRootCmd("") 39 | rootCmd.SetArgs([]string{"sync", "--confirm", "--disable-cleanup", "--debug"}) 40 | 41 | r.NoError(rootCmd.Execute()) 42 | 43 | testutils.RequireFileContains(r, "cloned/repo-1/.eslintrc.js", "indent: ['error', 2]") 44 | } 45 | -------------------------------------------------------------------------------- /pkg/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/caarlos0/log" 5 | "github.com/spf13/cobra" 6 | 7 | "github.com/ilaif/goplicate/pkg" 8 | "github.com/ilaif/goplicate/pkg/git" 9 | "github.com/ilaif/goplicate/pkg/shared" 10 | "github.com/ilaif/goplicate/pkg/utils" 11 | ) 12 | 13 | func NewRunCmd() *cobra.Command { 14 | runCmd := &cobra.Command{ 15 | Use: "run", 16 | Short: "Sync the project in the current directory", 17 | Args: cobra.MaximumNArgs(1), 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | log.Debug("Executing run command") 20 | ctx := cmd.Context() 21 | 22 | _, chToOrigWorkdir, err := utils.ChWorkdir(args) 23 | if err != nil { 24 | return err 25 | } 26 | defer chToOrigWorkdir() 27 | 28 | cloner := git.NewCloner() 29 | if !runFlagsOpts.disableCleanup { 30 | defer cloner.Close() 31 | } 32 | 33 | sharedState := &shared.State{ 34 | Message: runFlagsOpts.message, 35 | } 36 | 37 | if err := pkg.Run(ctx, cloner, sharedState, pkg.NewRunOpts( 38 | runFlagsOpts.dryRun, 39 | runFlagsOpts.confirm, 40 | runFlagsOpts.publish, 41 | runFlagsOpts.allowDirty, 42 | runFlagsOpts.force, 43 | runFlagsOpts.stashChanges, 44 | runFlagsOpts.baseBranch, 45 | runFlagsOpts.branch, 46 | )); err != nil { 47 | return err 48 | } 49 | 50 | return nil 51 | }, 52 | } 53 | 54 | applyRunFlags(runCmd) 55 | 56 | return runCmd 57 | } 58 | -------------------------------------------------------------------------------- /pkg/config/projects_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | 6 | "github.com/ilaif/goplicate/pkg/utils" 7 | ) 8 | 9 | const ( 10 | defaultProjectsConfigFilename = ".goplicate-projects.yaml" 11 | ) 12 | 13 | func LoadProjectsConfig() (*ProjectsConfig, error) { 14 | cfg := &ProjectsConfig{} 15 | if err := utils.ReadYaml(defaultProjectsConfigFilename, cfg); err != nil { 16 | return nil, errors.Wrap(err, "Failed to load projects config") 17 | } 18 | 19 | if err := cfg.Validate(); err != nil { 20 | return nil, errors.Wrap(err, "Failed to validate projects config") 21 | } 22 | 23 | return cfg, nil 24 | } 25 | 26 | type ProjectsConfig struct { 27 | Projects []Project `yaml:"projects"` 28 | } 29 | 30 | func (pc *ProjectsConfig) Validate() error { 31 | for _, project := range pc.Projects { 32 | if err := project.Validate(); err != nil { 33 | return errors.Wrap(err, "A project is invalid") 34 | } 35 | } 36 | 37 | return nil 38 | } 39 | 40 | type Project struct { 41 | Location Source `yaml:"location"` 42 | } 43 | 44 | func (p *Project) Validate() error { 45 | if (p.Location.Repository != "" && p.Location.Path != "") || (p.Location.Repository == "" && p.Location.Path == "") { 46 | return errors.New("Exactly one of 'repository', 'path' should be specified") 47 | } 48 | 49 | if err := p.Location.Validate(); err != nil { 50 | return errors.Wrap(err, "'location' is invalid") 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/flags.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var runFlagsOpts struct { 8 | dryRun bool 9 | confirm bool 10 | publish bool 11 | allowDirty bool 12 | force bool 13 | stashChanges bool 14 | disableCleanup bool 15 | baseBranch string 16 | branch string 17 | message string 18 | } 19 | 20 | func applyRunFlags(cmd *cobra.Command) { 21 | cmd.Flags().BoolVar(&runFlagsOpts.dryRun, "dry-run", false, "do not execute any changes") 22 | cmd.Flags().BoolVarP(&runFlagsOpts.confirm, "confirm", "y", false, "ask for confirmation") 23 | cmd.Flags().BoolVar(&runFlagsOpts.publish, "publish", false, 24 | "publish changes by checking out a new branch, committing, pushing and creating a GitHub pull request", 25 | ) 26 | cmd.Flags().BoolVar(&runFlagsOpts.allowDirty, "allow-dirty", false, "allow a dirty working tree when publishing") 27 | cmd.Flags().BoolVar(&runFlagsOpts.force, "force", false, "perform all actions even if there are no updates") 28 | cmd.Flags().BoolVar(&runFlagsOpts.stashChanges, "stash-changes", false, 29 | "if the working tree is dirty, stash changes before running, and restore them when done", 30 | ) 31 | cmd.Flags().BoolVar(&runFlagsOpts.disableCleanup, "disable-cleanup", false, "disable cleanup of cloned repositories") 32 | cmd.Flags().StringVar(&runFlagsOpts.baseBranch, "base", "", "base git branch to perform updates to") 33 | cmd.Flags().StringVar(&runFlagsOpts.branch, "branch", "", "name of the new branch to be checked out") 34 | cmd.Flags().StringVar(&runFlagsOpts.message, "message", "", "pull request description message. supports markdown.") 35 | } 36 | -------------------------------------------------------------------------------- /pkg/config/source.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // Source a path to a file. Can be from a `repository` if one is specified. Otherwise, assumes a local path. 11 | type Source struct { 12 | Path string `yaml:"path"` 13 | 14 | Repository RepositoryURI `yaml:"repository"` 15 | Tag string `yaml:"tag"` 16 | Branch string `yaml:"branch"` 17 | ClonePath string `yaml:"clone-path"` 18 | } 19 | 20 | func (s *Source) String() string { 21 | if s.Repository == "" { 22 | return s.Path 23 | } 24 | 25 | source := string(s.Repository) 26 | if s.Tag != "" { 27 | source += fmt.Sprintf("@%s", s.Tag) 28 | } 29 | if s.Branch != "" { 30 | source += fmt.Sprintf("@(%s)", s.Branch) 31 | } 32 | if s.Path != "" { 33 | source += fmt.Sprintf("/%s", s.Path) 34 | } 35 | 36 | return source 37 | } 38 | 39 | func (s *Source) Validate() error { 40 | if s.Repository == "" && s.Path == "" { 41 | return errors.New("At least one of 'repository', 'path' should be specified") 42 | } 43 | 44 | if s.Repository != "" { 45 | if err := s.Repository.Validate(); err != nil { 46 | return errors.Wrap(err, "'repository' is invalid") 47 | } 48 | } 49 | 50 | if s.Tag != "" && s.Branch != "" { 51 | return errors.New("Only one of 'branch', 'tag' can be specified") 52 | } 53 | 54 | if s.Repository == "" && (s.Tag != "" || s.Branch != "") { 55 | return errors.New("'branch' or 'tag' require 'repository' to be specified") 56 | } 57 | 58 | return nil 59 | } 60 | 61 | type RepositoryURI string 62 | 63 | func (r RepositoryURI) Validate() error { 64 | if _, err := url.ParseRequestURI(string(r)); err != nil { 65 | return errors.Errorf("'%s' is not a valid URI", string(r)) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/utils/interactive.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/AlecAivazis/survey/v2" 11 | "github.com/AlecAivazis/survey/v2/terminal" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // PromptUserYesNoQuestion ask a question and wait for user input. 16 | func PromptUserYesNoQuestion(question string, confirm bool) (bool, error) { 17 | if confirm { 18 | return true, nil 19 | } 20 | 21 | var continueToAuth bool 22 | 23 | if err := survey.AskOne(&survey.Confirm{ 24 | Message: question, 25 | }, &continueToAuth); err != nil { 26 | if err == terminal.InterruptErr { 27 | return false, errors.Wrap(err, "user interrupt") 28 | } 29 | 30 | return false, errors.Wrap(err, "prompt error") 31 | } 32 | 33 | return continueToAuth, nil 34 | } 35 | 36 | // OpenTextEditor opens the default text editor and capturing its input. 37 | func OpenTextEditor(ctx context.Context, initMsg string) (string, error) { 38 | editor := os.Getenv("EDITOR") 39 | if editor == "" { 40 | editor = "vi" 41 | } 42 | 43 | f, err := ioutil.TempFile("", "go-editor") 44 | if err != nil { 45 | return "", errors.Wrap(err, "Failed to create temp file") 46 | } 47 | defer os.Remove(f.Name()) 48 | 49 | if initMsg != "" { 50 | if _, err := f.WriteString(initMsg); err != nil { 51 | return "", errors.Wrap(err, "Failed to write init message to temp file") 52 | } 53 | } 54 | 55 | cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("%s %s", editor, f.Name())) // nolint:gosec 56 | cmd.Stdin = os.Stdin 57 | cmd.Stdout = os.Stdout 58 | cmd.Stderr = os.Stderr 59 | 60 | if err := cmd.Run(); err != nil { 61 | return "", errors.Wrap(err, "Failed to run text editor") 62 | } 63 | 64 | bytes, err := ioutil.ReadFile(f.Name()) 65 | if err != nil { 66 | return "", errors.Wrap(err, "Failed to read temp file") 67 | } 68 | 69 | return string(bytes), nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/caarlos0/log" 5 | "github.com/pkg/errors" 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ilaif/goplicate/pkg" 9 | "github.com/ilaif/goplicate/pkg/config" 10 | "github.com/ilaif/goplicate/pkg/git" 11 | "github.com/ilaif/goplicate/pkg/shared" 12 | "github.com/ilaif/goplicate/pkg/utils" 13 | ) 14 | 15 | func NewSyncCmd() *cobra.Command { 16 | syncCmd := &cobra.Command{ 17 | Use: "sync", 18 | Short: "Sync multiple projects via a configuration file", 19 | Args: cobra.MaximumNArgs(1), 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | log.Debug("Executing sync command") 22 | ctx := cmd.Context() 23 | 24 | _, chToOrigWorkdir, err := utils.ChWorkdir(args) 25 | if err != nil { 26 | return err 27 | } 28 | defer chToOrigWorkdir() 29 | 30 | cfg, err := config.LoadProjectsConfig() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | workdir := utils.MustGetwd() 36 | cloner := git.NewCloner() 37 | if !runFlagsOpts.disableCleanup { 38 | defer cloner.Close() 39 | } 40 | 41 | sharedState := &shared.State{ 42 | Message: runFlagsOpts.message, 43 | } 44 | 45 | for _, project := range cfg.Projects { 46 | projectAbsPath, err := pkg.ResolveSourcePath(ctx, project.Location, workdir, cloner) 47 | if err != nil { 48 | return errors.Wrap(err, "Failed to resolve source") 49 | } 50 | 51 | log.Infof("Syncing project %s...", projectAbsPath) 52 | log.IncreasePadding() 53 | 54 | if err := utils.Chdir(projectAbsPath); err != nil { 55 | return err 56 | } 57 | 58 | if err := pkg.Run(ctx, cloner, sharedState, pkg.NewRunOpts( 59 | runFlagsOpts.dryRun, 60 | runFlagsOpts.confirm, 61 | runFlagsOpts.publish, 62 | runFlagsOpts.allowDirty, 63 | runFlagsOpts.force, 64 | runFlagsOpts.stashChanges, 65 | runFlagsOpts.baseBranch, 66 | runFlagsOpts.branch, 67 | )); err != nil { 68 | return errors.Wrapf(err, "Failed to sync project '%s'", projectAbsPath) 69 | } 70 | 71 | log.DecreasePadding() 72 | log.Infof("Done syncing project %s", projectAbsPath) 73 | } 74 | 75 | log.Infof("Syncing complete") 76 | 77 | return nil 78 | }, 79 | } 80 | 81 | applyRunFlags(syncCmd) 82 | 83 | return syncCmd 84 | } 85 | -------------------------------------------------------------------------------- /pkg/diff.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/samber/lo" 8 | "github.com/sergi/go-diff/diffmatchpatch" 9 | ) 10 | 11 | // linesDiff get the string diff between two line slices 12 | func linesDiff(lines1, lines2 []string) string { 13 | dmp := diffmatchpatch.New() 14 | 15 | str1dmp, str2dmp, dmpStrings := dmp.DiffLinesToChars(padLines(lines1), padLines(lines2)) 16 | diffs := dmp.DiffMain(str1dmp, str2dmp, false) 17 | diffs = dmp.DiffCharsToLines(diffs, dmpStrings) 18 | 19 | var newDiffs []diffmatchpatch.Diff 20 | 21 | hasDiff := false 22 | 23 | for _, diff := range diffs { 24 | switch diff.Type { 25 | case diffmatchpatch.DiffDelete, diffmatchpatch.DiffInsert: 26 | hasDiff = true 27 | } 28 | 29 | switch diff.Type { 30 | case diffmatchpatch.DiffDelete: 31 | diff.Text = fmt.Sprintf("-%s", diff.Text[1:]) 32 | case diffmatchpatch.DiffInsert: 33 | diff.Text = fmt.Sprintf("+%s", diff.Text[1:]) 34 | } 35 | 36 | newDiffs = append(newDiffs, diff) 37 | } 38 | 39 | if !hasDiff { 40 | return "" 41 | } 42 | 43 | diff := dmp.DiffPrettyText(newDiffs) 44 | diffLines := strings.Split(diff, "\n") 45 | 46 | diffLineNos := []int{} 47 | for i, line := range diffLines { 48 | if strings.HasPrefix(line, "\x1b") { 49 | diffLineNos = append(diffLineNos, i) 50 | } 51 | } 52 | 53 | scopedLineNos := []int{} 54 | for _, lineNo := range diffLineNos { 55 | start := lo.Max([]int{lineNo - 3, 0}) 56 | end := lo.Min([]int{lineNo + 3, len(diffLines)}) 57 | for i := start; i < end; i++ { 58 | scopedLineNos = append(scopedLineNos, i) 59 | } 60 | } 61 | 62 | scopedLineNos = lo.Uniq(scopedLineNos) 63 | 64 | scopedDiff := []string{} 65 | 66 | if len(scopedLineNos) > 0 && scopedLineNos[0] > 0 { 67 | scopedDiff = append(scopedDiff, "...") 68 | } 69 | 70 | for i, lineNo := range scopedLineNos { 71 | if i > 0 && lineNo > scopedLineNos[i-1]+1 { 72 | scopedDiff = append(scopedDiff, "...") 73 | } 74 | 75 | scopedDiff = append(scopedDiff, diffLines[lineNo]) 76 | } 77 | 78 | if len(scopedLineNos) > 0 && scopedLineNos[len(scopedLineNos)-1] < len(diffLines)-1 { 79 | scopedDiff = append(scopedDiff, "...") 80 | } 81 | 82 | return strings.Join(scopedDiff, "\n") 83 | } 84 | 85 | func padLines(lines []string) string { 86 | lines = lo.Map(lines, func(line string, _ int) string { return " " + line }) 87 | 88 | return strings.Join(lines, "\n") 89 | } 90 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ilaif/goplicate 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.5 7 | github.com/caarlos0/log v0.1.6 8 | github.com/go-git/go-git/v5 v5.4.2 9 | github.com/otiai10/copy v1.7.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/pkg/fileutils v0.0.0-20181114200823-d734b7f202ba 12 | github.com/samber/lo v1.27.0 13 | github.com/sergi/go-diff v1.1.0 14 | github.com/spf13/cobra v1.5.0 15 | github.com/stretchr/testify v1.8.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/Microsoft/go-winio v0.4.16 // indirect 21 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect 22 | github.com/acomagu/bufpipe v1.0.3 // indirect 23 | github.com/aymanbagabas/go-osc52 v1.0.3 // indirect 24 | github.com/charmbracelet/lipgloss v0.6.1-0.20220911181249-6304a734e792 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/emirpasic/gods v1.12.0 // indirect 27 | github.com/go-git/gcfg v1.5.0 // indirect 28 | github.com/go-git/go-billy/v5 v5.3.1 // indirect 29 | github.com/google/go-cmp v0.5.8 // indirect 30 | github.com/imdario/mergo v0.3.12 // indirect 31 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 32 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 33 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 34 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 36 | github.com/mattn/go-colorable v0.1.2 // indirect 37 | github.com/mattn/go-isatty v0.0.16 // indirect 38 | github.com/mattn/go-runewidth v0.0.13 // indirect 39 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 40 | github.com/mitchellh/go-homedir v1.1.0 // indirect 41 | github.com/muesli/reflow v0.3.0 // indirect 42 | github.com/muesli/termenv v0.12.1-0.20220901123159-d729275e0977 // indirect 43 | github.com/pmezard/go-difflib v1.0.0 // indirect 44 | github.com/rivo/uniseg v0.4.2 // indirect 45 | github.com/spf13/pflag v1.0.5 // indirect 46 | github.com/xanzy/ssh-agent v0.3.0 // indirect 47 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 48 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 49 | golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect 50 | golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 // indirect 51 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect 52 | golang.org/x/text v0.3.3 // indirect 53 | gopkg.in/warnings.v0 v0.1.2 // indirect 54 | ) 55 | -------------------------------------------------------------------------------- /pkg/git/cloner.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "regexp" 7 | 8 | "github.com/caarlos0/log" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ilaif/goplicate/pkg/utils" 12 | ) 13 | 14 | type Cloner interface { 15 | Clone( 16 | ctx context.Context, 17 | uri, branch, fixedClonePath string, 18 | ) (clonePath string, err error) 19 | Close() 20 | } 21 | 22 | var ( 23 | validPathRegexp = regexp.MustCompile(`[^a-zA-Z0-9_-]`) 24 | ) 25 | 26 | // Cloner manages cloned git repositories 27 | type cloner struct { 28 | repositories map[string]string 29 | } 30 | 31 | func NewCloner() Cloner { 32 | return &cloner{ 33 | repositories: make(map[string]string), 34 | } 35 | } 36 | 37 | var _ Cloner = &cloner{} 38 | 39 | // Clone clones the repository into a temporary dir and returns it. 40 | // Caches to avoid cloning the same repository twice. 41 | func (c *cloner) Clone( 42 | ctx context.Context, 43 | uri, branch, fixedClonePath string, 44 | ) (string, error) { 45 | if tempdir, ok := c.repositories[uri]; ok { 46 | log.Debugf("Found repository '%s' in cache in directory '%s'", uri, tempdir) 47 | 48 | // If there's a clone path and its different from an existing one in 49 | // the same directory, then we want to symlink to be able to reference it 50 | if fixedClonePath != "" && tempdir != fixedClonePath { 51 | if err := os.Symlink(tempdir, fixedClonePath); err != nil { 52 | return "", errors.Wrapf(err, "Failed to create symlink '%s' for '%s'", tempdir, fixedClonePath) 53 | } 54 | } 55 | 56 | return tempdir, nil 57 | } 58 | 59 | dirPattern := validPathRegexp.ReplaceAllString("_goplicate_"+uri, "_") 60 | 61 | var err error 62 | tempdir := fixedClonePath 63 | if tempdir != "" { 64 | if err := os.MkdirAll(tempdir, 0750); err != nil { 65 | return "", errors.Wrapf(err, "Failed to create dir '%s'", tempdir) 66 | } 67 | } else { 68 | tempdir, err = os.MkdirTemp(os.TempDir(), dirPattern) 69 | if err != nil { 70 | return "", errors.Wrapf(err, "Failed to create tempdir '%s'", dirPattern) 71 | } 72 | } 73 | 74 | cmdRunner := utils.NewCommandRunner(tempdir) 75 | 76 | args := []string{"clone", "--depth", "1", uri, "."} 77 | if branch != "" { 78 | args = append(args, "--branch", branch) 79 | } 80 | 81 | log.Infof("Cloning '%s'", uri) 82 | 83 | if output, err := cmdRunner.Run(ctx, "git", args...); err != nil { 84 | return "", errors.Wrapf(err, "Failed to clone repository '%s': %s", uri, output) 85 | } 86 | 87 | c.repositories[uri] = tempdir 88 | 89 | return tempdir, nil 90 | } 91 | 92 | func (c *cloner) Close() { 93 | for uri, tempdir := range c.repositories { 94 | _ = os.RemoveAll(tempdir) 95 | delete(c.repositories, uri) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Goplicate 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologizing to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behavior include: 26 | 27 | * The use of sexualized language or imagery, and sexual attention or 28 | advances 29 | * Trolling, insulting or derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or email 32 | address, without their explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying and enforcing our standards of 39 | acceptable behavior and will take appropriate and fair corrective action in 40 | response to any instances of unacceptable behavior. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or reject 43 | comments, commits, code, wiki edits, issues, and other contributions that are 44 | not aligned to this Code of Conduct, or to ban 45 | temporarily or permanently any contributor for other behaviors that they deem 46 | inappropriate, threatening, offensive, or harmful. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies within all community spaces, and also applies when 51 | an individual is officially representing the community in public spaces. 52 | Examples of representing our community include using an official e-mail address, 53 | posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported to the community leaders responsible for enforcement at . 60 | All complaints will be reviewed and investigated promptly and fairly. 61 | 62 | All community leaders are obligated to respect the privacy and security of the 63 | reporter of any incident. 64 | 65 | ## Attribution 66 | 67 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 68 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 69 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), 70 | and was generated by [contributing-gen](https://github.com/bttger/contributing-gen). 71 | -------------------------------------------------------------------------------- /pkg/target.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/caarlos0/log" 8 | "github.com/pkg/errors" 9 | "github.com/pkg/fileutils" 10 | "github.com/samber/lo" 11 | 12 | "github.com/ilaif/goplicate/pkg/config" 13 | "github.com/ilaif/goplicate/pkg/git" 14 | "github.com/ilaif/goplicate/pkg/utils" 15 | ) 16 | 17 | func RunTarget(ctx context.Context, target config.Target, cloner git.Cloner, dryRun, confirm bool) (bool, error) { 18 | workdir := utils.MustGetwd() 19 | 20 | sourcePath, err := ResolveSourcePath(ctx, target.Source, workdir, cloner) 21 | if err != nil { 22 | return false, errors.Wrapf(err, "Failed to resolve source '%s'", target.Source.String()) 23 | } 24 | 25 | if target.SyncInitial { 26 | if _, err := os.Stat(target.Path); errors.Is(err, os.ErrNotExist) { 27 | log.Infof("Syncing initial state of '%s' from '%s'", target.Path, sourcePath) 28 | if err := fileutils.CopyFile(target.Path, sourcePath); err != nil { 29 | return false, errors.Wrapf(err, "Failed to copy '%s' to '%s'", sourcePath, target.Path) 30 | } 31 | } 32 | } 33 | 34 | targetBlocks, err := parseBlocksFromFile(target.Path, nil) 35 | if err != nil { 36 | return false, errors.Wrap(err, "Failed to parse target blocks") 37 | } 38 | 39 | params := map[string]interface{}{} 40 | for _, paramsSource := range target.Params { 41 | paramsPath, err := ResolveSourcePath(ctx, paramsSource, workdir, cloner) 42 | if err != nil { 43 | return false, errors.Wrapf(err, "Failed to resolve source '%s'", paramsSource.String()) 44 | } 45 | 46 | var curParams map[string]interface{} 47 | if err := utils.ReadYaml(paramsPath, &curParams); err != nil { 48 | return false, errors.Wrap(err, "Failed to parse params") 49 | } 50 | params = lo.Assign(params, curParams) 51 | } 52 | 53 | sourceBlocks, err := parseBlocksFromFile(sourcePath, params) 54 | if err != nil { 55 | return false, errors.Wrap(err, "Failed to parse source blocks") 56 | } 57 | 58 | anyDiff := false 59 | 60 | for _, targetBlock := range targetBlocks { 61 | if targetBlock.Name == "" { 62 | continue 63 | } 64 | 65 | sourceBlock := sourceBlocks.Get(targetBlock.Name) 66 | if sourceBlock == nil { 67 | log.Warnf("Target '%s': Block '%s' not found. Skipping", target.Path, targetBlock.Name) 68 | 69 | continue 70 | } 71 | 72 | diff := targetBlock.Compare(sourceBlock.Lines) 73 | if diff != "" { 74 | log.Infof("Target '%s': Block '%s' needs to be updated. Diff:\n%s\n", target.Path, targetBlock.Name, diff) 75 | 76 | targetBlock.SetLines(sourceBlock.Lines) 77 | anyDiff = true 78 | } 79 | } 80 | 81 | if !anyDiff { 82 | return false, nil 83 | } 84 | 85 | if dryRun { 86 | log.Infof("Target '%s': In dry-run mode - Not performing any changes", target.Path) 87 | 88 | return false, nil 89 | } 90 | 91 | question := "Do you want to apply the above changes?" 92 | answer, err := utils.PromptUserYesNoQuestion(question, confirm) 93 | if err != nil { 94 | return false, err 95 | } 96 | 97 | if answer { 98 | if err := utils.WriteStringToFile(target.Path, targetBlocks.Render()); err != nil { 99 | return false, err 100 | } 101 | 102 | log.Infof("Target '%s': Updated", target.Path) 103 | } else { 104 | log.Infof("Target '%s': Skipped", target.Path) 105 | } 106 | 107 | return true, nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/run.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/caarlos0/log" 7 | "github.com/pkg/errors" 8 | 9 | "github.com/ilaif/goplicate/pkg/config" 10 | "github.com/ilaif/goplicate/pkg/git" 11 | "github.com/ilaif/goplicate/pkg/shared" 12 | "github.com/ilaif/goplicate/pkg/utils" 13 | ) 14 | 15 | type RunOpts struct { 16 | DryRun bool 17 | Confirm bool 18 | Publish bool 19 | AllowDirty bool 20 | Force bool 21 | StashChanges bool 22 | BaseBranch string 23 | Branch string 24 | } 25 | 26 | func NewRunOpts( 27 | dryRun, confirm, publish, allowDirty, force, stashChanges bool, 28 | baseBranch, branch string, 29 | ) *RunOpts { 30 | return &RunOpts{ 31 | DryRun: dryRun, 32 | Confirm: confirm, 33 | Publish: publish, 34 | AllowDirty: allowDirty, 35 | Force: force, 36 | StashChanges: stashChanges, 37 | BaseBranch: baseBranch, 38 | Branch: branch, 39 | } 40 | } 41 | 42 | func Run( 43 | ctx context.Context, 44 | cloner git.Cloner, 45 | sharedState *shared.State, 46 | runOpts *RunOpts, 47 | ) error { 48 | cfg, err := config.LoadProjectConfig() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | updatedTargetPaths := []string{} 54 | 55 | if cfg.SyncConfig != nil { 56 | target := *cfg.SyncConfig 57 | 58 | if updated, err := RunTarget(ctx, target, cloner, runOpts.DryRun, runOpts.Confirm); err != nil { 59 | return errors.Wrapf(err, "Target '%s'", target.Path) 60 | } else if updated { 61 | updatedTargetPaths = append(updatedTargetPaths, target.Path) 62 | } 63 | 64 | // Reload the config 65 | cfg, err = config.LoadProjectConfig() 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | publisher := git.NewPublisher(sharedState, runOpts.BaseBranch, utils.MustGetwd(), runOpts.Branch) 72 | 73 | if !runOpts.DryRun && runOpts.Publish { 74 | if err := publisher.Init(ctx); err != nil { 75 | return errors.Wrap(err, "Failed to initialize git") 76 | } 77 | 78 | if !publisher.IsClean() { 79 | if runOpts.StashChanges { 80 | restoreStashedChanges, err := publisher.StashChanges(ctx) 81 | if err != nil { 82 | return errors.Wrap(err, "Failed to stash changes") 83 | } 84 | 85 | defer func() { 86 | if err := restoreStashedChanges(); err != nil { 87 | log.IncreasePadding() 88 | log.WithError(err).Warn("Cleanup: Failed to restore stashed changes") 89 | log.DecreasePadding() 90 | } 91 | }() 92 | } else if !runOpts.AllowDirty { 93 | return errors.New("Git worktree is not clean. Please commit or stash changes before running again") 94 | } 95 | } 96 | } 97 | 98 | for _, target := range cfg.Targets { 99 | if updated, err := RunTarget(ctx, target, cloner, runOpts.DryRun, runOpts.Confirm); err != nil { 100 | return errors.Wrapf(err, "Target '%s'", target.Path) 101 | } else if updated { 102 | updatedTargetPaths = append(updatedTargetPaths, target.Path) 103 | } 104 | } 105 | 106 | if !runOpts.Force && len(updatedTargetPaths) == 0 { 107 | return nil 108 | } 109 | 110 | if !runOpts.DryRun { 111 | for _, hook := range cfg.Hooks.Post { 112 | if err := RunHook(ctx, hook); err != nil { 113 | return err 114 | } 115 | } 116 | } 117 | 118 | if !runOpts.DryRun && runOpts.Publish { 119 | question := "Do you want to publish the above changes?" 120 | if answer, err := utils.PromptUserYesNoQuestion(question, runOpts.Confirm); err != nil { 121 | return err 122 | } else if answer { 123 | if err := publisher.Publish(ctx, updatedTargetPaths, runOpts.Confirm); err != nil { 124 | return errors.Wrap(err, "Failed to publish changes") 125 | } 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /pkg/blocks_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/ilaif/goplicate/pkg/utils" 9 | ) 10 | 11 | func TestParseBlocksFromFile(t *testing.T) { 12 | a := assert.New(t) 13 | 14 | tests := []struct { 15 | file string 16 | error bool 17 | expectedBlocks Blocks 18 | }{ 19 | { 20 | file: "testdata/blocks/valid.yaml", 21 | error: false, 22 | expectedBlocks: Blocks{ 23 | { 24 | Name: "", 25 | Lines: []string{ 26 | "repos:", 27 | }, 28 | }, 29 | { 30 | Name: "common", 31 | Lines: []string{ 32 | " # goplicate(name=common,pos=start)", 33 | " - name: common", 34 | " hooks:", 35 | " - id: my-common-pre-commit-hook", 36 | " # goplicate(name=common,pos=end)", 37 | }, 38 | }, 39 | { 40 | Name: "", 41 | Lines: []string{ 42 | " - name: local", 43 | " hooks:", 44 | " - id: my-project-1-pre-commit-hook", 45 | }, 46 | }, 47 | { 48 | Name: "external", 49 | Lines: []string{ 50 | " # goplicate_start(name=external)", 51 | " - name: external", 52 | " hooks:", 53 | " - id: my-external-pre-commit-hook", 54 | " # goplicate_end(name=external)", 55 | }, 56 | }, 57 | { 58 | Name: "new", 59 | Lines: []string{ 60 | " # goplicate-start:new", 61 | " - name: new", 62 | " hooks:", 63 | " - id: my-new-pre-commit-hook", 64 | " # goplicate-end:new", 65 | }, 66 | }, 67 | { 68 | Name: "", 69 | Lines: []string{ 70 | "", 71 | }, 72 | }, 73 | }, 74 | }, 75 | } 76 | 77 | for _, test := range tests { 78 | t.Run(test.file, func(t *testing.T) { 79 | blocks, err := parseBlocksFromFile(test.file, nil) 80 | a.NoError(err) 81 | 82 | a.Equal(test.expectedBlocks, blocks) 83 | }) 84 | } 85 | } 86 | 87 | func TestBlocksPadding(t *testing.T) { 88 | a := assert.New(t) 89 | 90 | tests := []struct { 91 | targetBlock Block 92 | sourceBlock Block 93 | }{ 94 | { 95 | targetBlock: Block{ 96 | Name: "common", 97 | Lines: []string{ 98 | " # goplicate-start:common", 99 | " value", 100 | " # goplicate-end:common", 101 | }, 102 | }, 103 | sourceBlock: Block{ 104 | Name: "common", 105 | Lines: []string{ 106 | " # goplicate-start:common", 107 | " value", 108 | " # goplicate-end:common", 109 | }, 110 | }, 111 | }, 112 | { 113 | targetBlock: Block{ 114 | Name: "common", 115 | Lines: []string{ 116 | " # goplicate-start:common", 117 | " value", 118 | " # goplicate-end:common", 119 | }, 120 | }, 121 | sourceBlock: Block{ 122 | Name: "common", 123 | Lines: []string{ 124 | " # goplicate-start:common", 125 | " value", 126 | " # goplicate-end:common", 127 | }, 128 | }, 129 | }, 130 | { 131 | targetBlock: Block{ 132 | Name: "common", 133 | Lines: []string{ 134 | " # goplicate-start:common", 135 | " value", 136 | " # goplicate-end:common", 137 | }, 138 | }, 139 | sourceBlock: Block{ 140 | Name: "common", 141 | Lines: []string{ 142 | " # goplicate-start:common", 143 | " value", 144 | " # goplicate-end:common", 145 | }, 146 | }, 147 | }, 148 | } 149 | 150 | for _, test := range tests { 151 | lines := test.targetBlock.padLines(test.sourceBlock.Lines) 152 | expectedLinePadding := utils.CountLeadingSpaces(test.targetBlock.Lines[0]) 153 | for _, line := range lines { 154 | actualLinePadding := utils.CountLeadingSpaces(line) 155 | a.Equal(expectedLinePadding, actualLinePadding) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /pkg/blocks.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/samber/lo" 12 | 13 | "github.com/ilaif/goplicate/pkg/utils" 14 | ) 15 | 16 | const ( 17 | ParamName = "name" 18 | ParamPos = "pos" 19 | 20 | PosStart = "start" 21 | PosEnd = "end" 22 | ) 23 | 24 | var ( 25 | PosList = []string{PosStart, PosEnd} 26 | 27 | // regex decomposition: 28 | // 1. empty spaces: \s* 29 | // 2. identifying comments: (#|\/\/|\/\*|\-\-|<\-\-) 30 | // 3. goplicate block format: goplicate_start|end(...params...) or goplicate-start: 31 | blockRegex = regexp.MustCompile(`\s*(#|\/\/|\/\*|\-\-|<\-\-)\s*goplicate([_\-](start|end))?(\((.*)\)|:(.*))`) 32 | ) 33 | 34 | type Block struct { 35 | Name string 36 | Lines []string 37 | } 38 | 39 | func (b *Block) Render() string { 40 | return strings.Join(b.Lines, "\n") 41 | } 42 | 43 | func (b *Block) Compare(lines []string) string { 44 | return linesDiff(b.Lines, b.padLines(lines)) 45 | } 46 | 47 | func (b *Block) SetLines(lines []string) { 48 | b.Lines = b.padLines(lines) 49 | } 50 | 51 | // padLines add a base indentation to match the one in this block (according to the first line) 52 | func (b *Block) padLines(lines []string) []string { 53 | ourIndent := 0 54 | if len(b.Lines) > 0 { 55 | ourIndent = utils.CountLeadingSpaces(b.Lines[0]) 56 | } 57 | theirIndent := 0 58 | if len(lines) > 0 { 59 | theirIndent = utils.CountLeadingSpaces(lines[0]) 60 | } 61 | indentAddition := ourIndent - theirIndent 62 | 63 | paddedLines := make([]string, len(lines)) 64 | for i, l := range lines { 65 | if indentAddition > 0 { 66 | paddedLines[i] = strings.Repeat(" ", indentAddition) + l 67 | } else { 68 | paddedLines[i] = l[-indentAddition:] 69 | } 70 | } 71 | 72 | return paddedLines 73 | } 74 | 75 | type Blocks []*Block 76 | 77 | func (b *Blocks) add(block *Block) { 78 | *b = append(*b, block) 79 | } 80 | 81 | func (b *Blocks) Get(name string) *Block { 82 | if name == "" { 83 | return nil 84 | } 85 | 86 | for _, block := range *b { 87 | if block.Name == name { 88 | return block 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (b *Blocks) Render() string { 96 | return strings.Join(lo.Map(*b, func(block *Block, _ int) string { 97 | return block.Render() 98 | }), "\n") 99 | } 100 | 101 | func parseBlocksFromFile(filename string, params map[string]interface{}) (Blocks, error) { 102 | fileBytes, err := utils.ReadFile(filename) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | var s string 108 | if params != nil { 109 | t, err := template.New("parse-blocks-tpl").Parse(string(fileBytes)) 110 | if err != nil { 111 | return nil, errors.Wrapf(err, "Failed to parse template for file '%s'", filename) 112 | } 113 | 114 | var tpl bytes.Buffer 115 | if err := t.Option("missingkey=error").Execute(&tpl, params); err != nil { 116 | return nil, errors.Wrapf(err, "Failed to execute template for file '%s'", filename) 117 | } 118 | 119 | s = tpl.String() 120 | } else { 121 | s = string(fileBytes) 122 | } 123 | 124 | lines := strings.Split(s, "\n") 125 | 126 | blocks, err := parseBlocksFromLines(lines) 127 | if err != nil { 128 | return nil, errors.Wrapf(err, "Failed to parse blocks in '%s'", filename) 129 | } 130 | 131 | return blocks, err 132 | } 133 | 134 | func parseBlocksFromLines(lines []string) (Blocks, error) { 135 | blocks := Blocks{} 136 | 137 | var startI int 138 | var curBlock *Block 139 | for i, l := range lines { 140 | params, err := parseBlockComment(l) 141 | if err != nil { 142 | return nil, err 143 | } else if params == nil { 144 | // if not a block comment, open an empty block 145 | if curBlock == nil { 146 | curBlock = &Block{Name: ""} 147 | } 148 | 149 | continue 150 | } 151 | 152 | if params.pos == PosStart && curBlock != nil && curBlock.Name == "" { 153 | // an empty block should be closed before processing the comment 154 | curBlock.Lines = lines[startI:i] 155 | blocks.add(curBlock) 156 | startI = i 157 | curBlock = nil 158 | } 159 | 160 | switch { 161 | case params.pos == PosStart && curBlock == nil: 162 | // if we see a block start with a nil curBlock, we'll initialize a new one 163 | curBlock = &Block{Name: params.name} 164 | case params.pos == PosEnd && curBlock != nil: 165 | // if we see a block end with a currently active curBlock, we'll close it 166 | curBlock.Lines = lines[startI : i+1] 167 | blocks.add(curBlock) 168 | startI = i + 1 169 | curBlock = nil 170 | default: 171 | return nil, errors.Errorf("Every block must have a position and cannot be nested "+ 172 | "or interleaved with other blocks. Params: '%+v'", params) 173 | } 174 | } 175 | 176 | if curBlock != nil { 177 | if curBlock.Name == "" { 178 | curBlock.Lines = lines[startI:] 179 | blocks.add(curBlock) 180 | } else { 181 | return nil, errors.Errorf("Every block must have an 'end' position") 182 | } 183 | } 184 | 185 | return blocks, nil 186 | } 187 | 188 | type blockParams struct { 189 | name string 190 | pos string 191 | } 192 | 193 | func parseBlockComment(l string) (*blockParams, error) { 194 | matches := blockRegex.FindStringSubmatch(l) 195 | if len(matches) != 7 { 196 | // if not a block comment, return nil 197 | return nil, nil 198 | } 199 | 200 | startEndBlock := matches[3] 201 | paramsStr := matches[5] 202 | if matches[6] != "" { 203 | // Assume the format is "goplicate-start:"" 204 | paramsStr = fmt.Sprintf("name=%s", matches[6]) 205 | } 206 | 207 | return parseBlockParams(startEndBlock, paramsStr) 208 | } 209 | 210 | func parseBlockParams(startEndBlock string, params string) (*blockParams, error) { 211 | bp := &blockParams{} 212 | 213 | if startEndBlock != "" { 214 | bp.pos = startEndBlock 215 | } 216 | 217 | for _, p := range strings.Split(params, ",") { 218 | splitP := strings.Split(p, "=") 219 | if len(splitP) != 2 { 220 | return nil, errors.Errorf("Block parameter '%s' is not of the form 'name=value'", p) 221 | } 222 | paramName := splitP[0] 223 | paramValue := splitP[1] 224 | switch paramName { 225 | case "name": 226 | bp.name = paramValue 227 | case "pos": 228 | bp.pos = paramValue 229 | default: 230 | return nil, errors.Errorf("Unknown block parameter name '%s'", p) 231 | } 232 | } 233 | 234 | if bp.name == "" { 235 | return nil, errors.Errorf("Block parameter 'name' cannot be empty") 236 | } 237 | 238 | if !lo.Contains(PosList, bp.pos) { 239 | return nil, errors.Errorf("Block parameter 'pos' must be one of %s", PosList) 240 | } 241 | 242 | return bp, nil 243 | } 244 | -------------------------------------------------------------------------------- /pkg/git/publisher.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/caarlos0/log" 9 | "github.com/go-git/go-git/v5" 10 | "github.com/pkg/errors" 11 | "github.com/samber/lo" 12 | 13 | "github.com/ilaif/goplicate/pkg/shared" 14 | "github.com/ilaif/goplicate/pkg/utils" 15 | ) 16 | 17 | // Publisher publishes changes to git, including opening PRs 18 | type Publisher struct { 19 | sharedState *shared.State 20 | baseBranch string 21 | dir string 22 | branch string 23 | 24 | cmdRunner *utils.CommandRunner 25 | repo *git.Repository 26 | status git.Status 27 | } 28 | 29 | func NewPublisher(sharedState *shared.State, baseBranch string, dir string, branch string) *Publisher { 30 | cmdRunner := utils.NewCommandRunner(dir) 31 | 32 | return &Publisher{sharedState: sharedState, baseBranch: baseBranch, dir: dir, branch: branch, cmdRunner: cmdRunner} 33 | } 34 | 35 | func (p *Publisher) Init(ctx context.Context) error { 36 | var err error 37 | 38 | log.Debugf("Opening repository '%s'", p.dir) 39 | p.repo, err = git.PlainOpen(p.dir) 40 | if err != nil { 41 | return errors.Wrap(err, "Failed to open repository") 42 | } 43 | 44 | log.Debug("Opening worktree") 45 | worktree, err := p.repo.Worktree() 46 | if err != nil { 47 | return errors.Wrap(err, "Failed to open worktree") 48 | } 49 | 50 | log.Debug("Getting worktree status") 51 | p.status, err = worktree.Status() 52 | if err != nil { 53 | return errors.Wrap(err, "Failed to get worktree status") 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (p *Publisher) StashChanges(ctx context.Context) (func() error, error) { 60 | log.Debug("Stashing working directory changes") 61 | if output, err := p.cmdRunner.Run(ctx, "git", "stash"); err != nil { 62 | return nil, errors.Wrapf(err, "Failed to stash local changes: %s", output) 63 | } 64 | 65 | return func() error { 66 | log.Debug("Cleanup: Un-stashing working directory changes") 67 | if output, err := p.cmdRunner.Run(ctx, "git", "stash", "pop"); err != nil { 68 | return errors.Wrapf(err, "Cleanup: Failed to restore local changes: %s", output) 69 | } 70 | 71 | return nil 72 | }, nil 73 | } 74 | 75 | func (p *Publisher) IsClean() bool { 76 | return p.status.IsClean() 77 | } 78 | 79 | func (p *Publisher) Publish(ctx context.Context, filePaths []string, confirm bool) error { 80 | log.Info("Publishing changes...") 81 | 82 | log.Debug("Fetching current branch name") 83 | origBranchName, err := p.cmdRunner.Run(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") 84 | if err != nil { 85 | return errors.Wrapf(err, "Failed to fetch current branch name: %s", origBranchName) 86 | } 87 | origBranchName = strings.Trim(origBranchName, "\n") 88 | 89 | if p.baseBranch != "" { 90 | log.Debugf("Checking out base branch '%s'", p.baseBranch) 91 | if output, err := p.cmdRunner.Run(ctx, "git", "checkout", p.baseBranch); err != nil { 92 | return errors.Wrapf(err, "Failed to checkout base branch '%s': %s", p.baseBranch, output) 93 | } 94 | } 95 | defer func() { 96 | log.Debugf("Cleanup: Checking out original branch '%s'", origBranchName) 97 | if output, err := p.cmdRunner.Run(ctx, "git", "checkout", string(origBranchName)); err != nil { 98 | log.WithError(err).Errorf("Cleanup: Failed to checkout back to original branch '%s': %s", p.baseBranch, output) 99 | } 100 | }() 101 | 102 | log.Debugf("Pulling from remote") 103 | if output, err := p.cmdRunner.Run(ctx, "git", "pull"); err != nil { 104 | return errors.Wrapf(err, "Failed to pull branch: %s", output) 105 | } 106 | 107 | log.Debug("Fetching HEAD reference") 108 | branchName := "chore/update-goplicate-snippets" 109 | if p.branch != "" { 110 | branchName = p.branch 111 | } 112 | 113 | log.Debugf("Deleting existing branch '%s' if exists", branchName) 114 | if output, err := p.cmdRunner.Run(ctx, "git", "branch", "-D", branchName); err != nil { 115 | log.WithError(err).Debugf("Failed to delete existing branch '%s': %s", branchName, output) 116 | } 117 | 118 | remoteOriginURL, err := p.cmdRunner.Run(ctx, "git", "config", "--get", "remote.origin.url") 119 | remoteOriginURL = strings.Trim(remoteOriginURL, "\n") 120 | if err != nil { 121 | return errors.Wrapf(err, "Failed to get remote origin url: %s", remoteOriginURL) 122 | } 123 | 124 | output, err := p.cmdRunner.Run(ctx, "git", "ls-remote", "--heads", remoteOriginURL, branchName) 125 | if err != nil { 126 | return errors.Wrapf(err, "Failed to list remote branches: %s", output) 127 | } 128 | if strings.Contains(output, fmt.Sprintf("refs/heads/%s", branchName)) { 129 | // Remote branch exists 130 | question := fmt.Sprintf("Found branch '%s' in origin. Do you want to delete it?", branchName) 131 | answer, err := utils.PromptUserYesNoQuestion(question, confirm) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | if answer { 137 | output, err := p.cmdRunner.Run(ctx, "git", "push", "-d", "origin", branchName) 138 | if err != nil { 139 | return errors.Wrapf(err, "Failed to delete existing remote branch '%s': %s", branchName, output) 140 | } 141 | } else { 142 | log.Infof("Skipped deletion of branch '%s'", branchName) 143 | } 144 | } 145 | 146 | log.Debugf("Checking out new branch '%s'", branchName) 147 | if output, err := p.cmdRunner.Run(ctx, "git", "checkout", "-b", branchName); err != nil { 148 | return errors.Wrapf(err, "Failed to checkout new branch '%s': %s", branchName, output) 149 | } 150 | 151 | filePaths = lo.Uniq(append(filePaths, lo.Keys(p.status)...)) 152 | for _, path := range filePaths { 153 | log.Debugf("Adding file '%s' to the worktree", path) 154 | if output, err := p.cmdRunner.Run(ctx, "git", "add", path); err != nil { 155 | return errors.Wrapf(err, "Failed to add files to the worktree: %s", output) 156 | } 157 | } 158 | 159 | log.Debug("Committing changes") 160 | commitMsg := "chore: update goplicate snippets" 161 | if output, err := p.cmdRunner.Run(ctx, "git", "commit", "-m", commitMsg); err != nil { 162 | return errors.Wrapf(err, "Failed to commit changes: %s", output) 163 | } 164 | 165 | log.Debug("Pushing changes") 166 | if output, err := p.cmdRunner.Run(ctx, "git", "push", "-u", "origin", branchName); err != nil { 167 | return errors.Wrapf(err, "Failed to push changes: %s", output) 168 | } 169 | 170 | prBody := "# Update goplicate snippets" 171 | // Populate the shared state with the user input 172 | if !confirm && p.sharedState.Message == "" { 173 | question := "Do you want to open a text editor to modify the change request message?" 174 | answer, err := utils.PromptUserYesNoQuestion(question, confirm) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | if answer { 180 | output, err := utils.OpenTextEditor(ctx, prBody) 181 | if err != nil { 182 | return errors.Wrap(err, "Failed to prompt for message") 183 | } 184 | 185 | p.sharedState.Message = output 186 | } 187 | } 188 | if p.sharedState.Message != "" { 189 | prBody = p.sharedState.Message 190 | } 191 | 192 | log.Debug("Creating pull request") 193 | resp, err := p.cmdRunner.Run(ctx, "gh", "pr", "create", "--title", commitMsg, "--body", prBody, "--head", branchName) 194 | resp = strings.TrimSuffix(resp, "\n") 195 | alreadyExists := strings.Contains(resp, "already exists:") 196 | if err != nil && !alreadyExists { 197 | return errors.Wrapf(err, "Failed to create a PR: %s", resp) 198 | } 199 | 200 | if alreadyExists { 201 | log.Warnf("PR already exists: %s", resp) 202 | } else { 203 | log.Infof("Created PR: %s", resp) 204 | } 205 | 206 | return nil 207 | } 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Goplicate 2 | 3 | 4 | 5 | --- 6 | 7 | Goplicate is a CLI tool that helps define common code or configuration snippets once and sync them to multiple projects. 8 | 9 | ## Why and how 10 | 11 | In cases where we have many snippets that are repeated between different repositories or projects, it becomes a real hassle to keep them up-to-date. 12 | 13 | We want to stay [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself). 14 | 15 | Goplicate achieves that by defining "blocks" around such shared snippets and automates their update via a shared source that contains the most up-to-date version of those snippets. 16 | 17 | ## Installation 18 | 19 | ### MacOS 20 | 21 | ```sh 22 | brew install ilaif/tap/goplicate 23 | brew upgrade ilaif/tap/goplicate 24 | ``` 25 | 26 | ### Install from source 27 | 28 | ```sh 29 | go install github.com/ilaif/goplicate/cmd/goplicate@latest 30 | ``` 31 | 32 | ## Usage 33 | 34 | `goplicate --help` 35 | 36 | ## Design principles 37 | 38 | * 🌵 Stay [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) - Write a configuration once, and have it synced across many projects. 39 | * 🤤 [Keep It Stupid Simple (KISS)](https://en.wikipedia.org/wiki/KISS_principle) - Treat configuration snippets as simple text, not assuming anything about structure. 40 | * 🙆🏻‍♀️ Allow flexibility, but not too much - Allow syncing whole files, or parts of them (currently, line-based). 41 | * 😎 Automate all the things - After an initial configuration, automates the rest. 42 | 43 | ## Features 44 | 45 | * Configure line-based blocks that should be synced across multiple projects and files. 46 | * See comfortable diffs while updating config files. 47 | * Template support using [Go Templates](https://pkg.go.dev/text/template) with dynamic parameters or conditions. 48 | * Sync multiple repositories with a single command. 49 | * Automatically run post hooks to validate that the updates worked well before opening a pull request. 50 | * Open a GitHub Pull Request (requires [GitHub CLI](https://cli.github.com/) to be installed and configured). 51 | 52 | ## Examples 53 | 54 | ### Quick start 55 | 56 | In the following simplified example, we'll sync an [eslint](https://eslint.org) configuration. 57 | 58 | We'll end up having the following folder structure: 59 | 60 | ```diff 61 | + shared-configs-repo/ 62 | + .eslintrc.js.tpl 63 | repo-1/ 64 | .eslintrc.js 65 | + .goplicate.yaml 66 | repo-2/ 67 | .eslintrc.js 68 | + .goplicate.yaml 69 | ... 70 | ``` 71 | 72 | 1️⃣ Choose a config file that some of its contents are copied across multiple projects, and add goplicate block comments for the `common-rules` section of your desire: 73 | 74 | `repo-1/.eslintrc.js`: 75 | 76 | ```diff 77 | module.exports = { 78 | "extends": "eslint:recommended", 79 | "rules": { 80 | + // goplicate-start:common-rules 81 | // enable additional rules 82 | "indent": ["error", 2], 83 | "linebreak-style": ["error", "unix"], 84 | "quotes": ["error", "double"], 85 | "semi": ["error", "always"], 86 | + // goplicate-end:common-rules 87 | 88 | // override configuration set by extending "eslint:recommended" 89 | "no-empty": "warn", 90 | "no-cond-assign": ["error", "always"], 91 | } 92 | } 93 | ``` 94 | 95 | 2️⃣ Create a separate, centralized repository to manage all of the shared config files. We'll name it `shared-configs-repo`. Then, add an `.eslintrc.js.tpl` file with the `common-rules` snippet that we want to sync: 96 | 97 | `shared-configs-repo/.eslint.js.tpl`: 98 | 99 | ```txt 100 | module.exports = { 101 | "rules": { 102 | // goplicate-start:common-rules 103 | // enable additional rules 104 | "indent": ["error", 4], 105 | "linebreak-style": ["error", "unix"], 106 | "quotes": ["error", "double"], 107 | "semi": ["error", "always"], 108 | // goplicate-end:common-rules 109 | } 110 | } 111 | ``` 112 | 113 | > Goplicate snippets are simply the sections of the config file that we'd like to sync. In this example, we've also added the surrounding configuration to make it more readable, but it's not really needed. 114 | 115 | 3️⃣ Go back to the original project, and create a `.goplicate.yaml` file in your project root folder: 116 | 117 | `repo-1/.goplicate.yaml`: 118 | 119 | ```yaml 120 | targets: 121 | - path: .eslintrc.js 122 | source: 123 | path: ../shared-configs-repo/.eslintrc.js.tpl 124 | ``` 125 | 126 | 4️⃣ Finally, run goplicate on the repository to sync any updates: 127 | 128 | 129 | 130 | ### Using a remote git repository 131 | 132 | In this example, we'll use a remote git repository as the source of the shared snippets instead of a local folder. 133 | 134 | 1️⃣ Fork [goplicate-example-repo-1](https://github.com/ilaif/goplicate-example-repo-1) and clone it. 135 | 136 | Looking inside, we see 2 files: 137 | 138 | `.eslintrc.js`: 139 | 140 | ```js 141 | module.exports = { 142 | extends: 'eslint:recommended', 143 | rules: { 144 | // goplicate-start:common-rules 145 | // enable additional rules 146 | indent: ['error', 4], 147 | 'linebreak-style': ['error', 'unix'], 148 | quotes: ['error', 'double'], 149 | semi: ['error', 'always'], 150 | // goplicate-end:common-rules 151 | 152 | // override configuration set by extending "eslint:recommended" 153 | 'no-empty': 'warn', 154 | 'no-cond-assign': ['error', 'always'], 155 | }, 156 | } 157 | ``` 158 | 159 | > Notice the `// goplicate-start:common-rules` and `// goplicate-end:common-rules` block annotations that will be synced by goplicate. 160 | 161 | `.goplicate.yaml`: 162 | 163 | ```yaml 164 | targets: 165 | - path: .eslintrc.js 166 | source: 167 | repository: https://github.com/ilaif/goplicate-example-shared-configs 168 | path: .eslintrc.js 169 | params: 170 | - repository: https://github.com/ilaif/goplicate-example-shared-configs 171 | path: params.yaml 172 | ``` 173 | 174 | If we go to [goplicate-example-shared-configs](https://github.com/ilaif/goplicate-example-shared-configs), we'll see that `.eslintrc.js` contains the `common-rules` source of truth with the `params.yaml` containing a parameter as well: 175 | 176 | `.eslintrc.js` in [goplicate-example-shared-configs](https://github.com/ilaif/goplicate-example-shared-configs): 177 | 178 | ```js 179 | module.exports = { 180 | rules: { 181 | // goplicate-start:common-rules 182 | // enable additional rules 183 | indent: ['error', {{.indent}}], 184 | 'linebreak-style': ['error', 'unix'], 185 | quotes: ['error', 'double'], 186 | semi: ['error', 'always'], 187 | // goplicate-end:common-rules 188 | }, 189 | } 190 | ``` 191 | 192 | `params.yaml` in [goplicate-example-shared-configs](https://github.com/ilaif/goplicate-example-shared-configs): 193 | 194 | ```yaml 195 | indent: 2 196 | ``` 197 | 198 | 2️⃣ From the cloned repository, run goplicate to create a new PR with synced changes: 199 | 200 | ```sh 201 | ~/git/oss/goplicate-example-repo-1 (main ✔) ᐅ goplicate run --publish 202 | • Cloning 'https://github.com/ilaif/goplicate-example-shared-configs' 203 | • Target '.eslintrc.js': Block 'common-rules' needs to be updated. Diff: 204 | // goplicate-start:common-rules 205 | // enable additional rules 206 | - indent: ['error', 4], 207 | + indent: ['error', 2], 208 | 'linebreak-style': ['error', 'unix'], 209 | quotes: ['error', 'double'], 210 | semi: ['error', 'always'], 211 | ... 212 | 213 | ? Do you want to apply the above changes? Yes 214 | • Target '.eslintrc.js': Updated 215 | ? Do you want to publish the above changes? Yes 216 | • Publishing changes... 217 | • Created PR: https://github.com/ilaif/goplicate-example-repo-1/pull/4 218 | ``` 219 | 220 | 3️⃣ Open the PR and review it! We'll see that the indentation indeed changed: 221 | 222 | ```diff 223 | rules: { 224 | // goplicate-start:common-rules 225 | // enable additional rules 226 | - indent: ['error', 4], 227 | + indent: ['error', 2], 228 | 'linebreak-style': ['error', 'unix'], 229 | quotes: ['error', 'double'], 230 | semi: ['error', 'always'], 231 | ``` 232 | 233 | 4️⃣ Finally, merge it and maintain consistency and standardization across your configuration files! 234 | 235 | ### More examples 236 | 237 | See the [Examples](https://github.com/ilaif/goplicate/tree/main/examples) folder for usage examples. 238 | 239 | ## Questions, bug reporting and feature requests 240 | 241 | You're more than welcome to [Create a new issue](https://github.com/ilaif/goplicate/issues/new) or contribute. 242 | 243 | ## Contributing 244 | 245 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. See [CONTRIBUTING.md](CONTRIBUTING.md) for more information. 246 | 247 | ## License 248 | 249 | Goplicate is licensed under the [MIT](https://choosealicense.com/licenses/mit/) license. For more information, please see the [LICENSE](LICENSE) file. 250 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to Goplicate 3 | 4 | First off, thanks for taking the time to contribute! ❤️ 5 | 6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 7 | 8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 9 | > 10 | > - Star the project 11 | > - Tweet about it 12 | > - Refer this project in your project's readme 13 | > - Mention the project at local meetups and tell your friends/colleagues 14 | 15 | 16 | ## Table of Contents 17 | 18 | - [Code of Conduct](#code-of-conduct) 19 | - [I Have a Question](#i-have-a-question) 20 | - [I Want To Contribute](#i-want-to-contribute) 21 | - [Reporting Bugs](#reporting-bugs) 22 | - [Suggesting Enhancements](#suggesting-enhancements) 23 | - [Your First Code Contribution](#your-first-code-contribution) 24 | - [Improving The Documentation](#improving-the-documentation) 25 | - [Styleguides](#styleguides) 26 | - [Commit Messages](#commit-messages) 27 | - [Join The Project Team](#join-the-project-team) 28 | 29 | ## Code of Conduct 30 | 31 | This project and everyone participating in it is governed by the 32 | [Goplicate Code of Conduct](https://github.com/ilaif/goplicate/blob/main/CODE_OF_CONDUCT.md). 33 | By participating, you are expected to uphold this code. Please report unacceptable behavior 34 | to . 35 | 36 | ## I Have a Question 37 | 38 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/ilaif/goplicate). 39 | 40 | Before you ask a question, it is best to search for existing [Issues](https://github.com/ilaif/goplicate/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 41 | 42 | If you then still feel the need to ask a question and need clarification, we recommend the following: 43 | 44 | - Open an [Issue](https://github.com/ilaif/goplicate/issues/new). 45 | - Provide as much context as you can about what you're running into. 46 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 47 | 48 | We will then take care of the issue as soon as possible. 49 | 50 | 64 | 65 | ## I Want To Contribute 66 | 67 | > ### Legal Notice 68 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 69 | 70 | ### Reporting Bugs 71 | 72 | 73 | #### Before Submitting a Bug Report 74 | 75 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 76 | 77 | - Make sure that you are using the latest version. 78 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/ilaif/goplicate). If you are looking for support, you might want to check [this section](#i-have-a-question)). 79 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/ilaif/goplicateissues?q=label%3Abug). 80 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 81 | - Collect information about the bug: 82 | - Stack trace (Traceback) 83 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 84 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 85 | - Possibly your input and the output 86 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 87 | 88 | 89 | #### How Do I Submit a Good Bug Report? 90 | 91 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 92 | 93 | 94 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 95 | 96 | - Open an [Issue](https://github.com/ilaif/goplicate/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 97 | - Explain the behavior you would expect and the actual behavior. 98 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 99 | - Provide the information you collected in the previous section. 100 | 101 | Once it's filed: 102 | 103 | - The project team will label the issue accordingly. 104 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 105 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 106 | 107 | 108 | 109 | ### Suggesting Enhancements 110 | 111 | This section guides you through submitting an enhancement suggestion for Goplicate, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 112 | 113 | 114 | #### Before Submitting an Enhancement 115 | 116 | - Make sure that you are using the latest version. 117 | - Read the [documentation](https://github.com/ilaif/goplicate) carefully and find out if the functionality is already covered, maybe by an individual configuration. 118 | - Perform a [search](https://github.com/ilaif/goplicate/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 119 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 120 | 121 | 122 | #### How Do I Submit a Good Enhancement Suggestion? 123 | 124 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/ilaif/goplicate/issues). 125 | 126 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 127 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 128 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 129 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 130 | - **Explain why this enhancement would be useful** to most Goplicate users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 131 | 132 | 133 | 134 | ### Your First Code Contribution 135 | 139 | 140 | ### Improving The Documentation 141 | 145 | 146 | ## Styleguides 147 | 148 | ### Commit Messages 149 | 152 | 153 | ## Join The Project Team 154 | 155 | 156 | 157 | ## Attribution 158 | 159 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 160 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ= 2 | github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= 3 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 4 | github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= 5 | github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= 6 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 7 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 8 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= 9 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= 10 | github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= 11 | github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 12 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= 13 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 14 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 15 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 16 | github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= 17 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 18 | github.com/caarlos0/log v0.1.6 h1:IbmpLDp7zTsoNOk0w0ARKebAg5nRSqP/3Nnp4ZREC+U= 19 | github.com/caarlos0/log v0.1.6/go.mod h1:BCSXWwgm3+stBxIPx09on4ydlPFhvrCZdo/IX1sWnmA= 20 | github.com/charmbracelet/lipgloss v0.6.1-0.20220911181249-6304a734e792 h1:VfX981snWr7d4yvFAJYCN3S2sOsweiM6BsqZgFPY65c= 21 | github.com/charmbracelet/lipgloss v0.6.1-0.20220911181249-6304a734e792/go.mod h1:sOPE4igPEyZ5Q75T0PYIMqA40cL+r0NrLlMJxr01aiE= 22 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 23 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 24 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 25 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 30 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 31 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 32 | github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= 33 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 34 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 35 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 36 | github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 37 | github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= 38 | github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 39 | github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= 40 | github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= 41 | github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= 42 | github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= 43 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 44 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 45 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 46 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 47 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 48 | github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= 49 | github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 50 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 51 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 52 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 53 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 54 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 55 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 56 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 57 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= 58 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 59 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 60 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 61 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 62 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 63 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 64 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 65 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 66 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 67 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 68 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 69 | github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 70 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 71 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 72 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 73 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 74 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 75 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 76 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 77 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 78 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 79 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 80 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 81 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 82 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 83 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 84 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 85 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 86 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 87 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 88 | github.com/muesli/termenv v0.12.1-0.20220901123159-d729275e0977 h1:Y0zb7SdTvzR44kY+Ybemf3Nu/tRW3CDJixgZD9/Y24I= 89 | github.com/muesli/termenv v0.12.1-0.20220901123159-d729275e0977/go.mod h1:bN6sPNtkiahdhHv2Xm6RGU16LSCxfbIZvMfqjOCfrR4= 90 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 91 | github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= 92 | github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= 93 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 94 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= 95 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 96 | github.com/otiai10/mint v1.3.3 h1:7JgpsBaN0uMkyju4tbYHu0mnM55hNKVYLsXmwr15NQI= 97 | github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 98 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 99 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 100 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 101 | github.com/pkg/fileutils v0.0.0-20181114200823-d734b7f202ba h1:6N4YMhhXMxaJf8EzKZU9YcE3Q9J2H0rbhmmfvmDOx9E= 102 | github.com/pkg/fileutils v0.0.0-20181114200823-d734b7f202ba/go.mod h1:Wr30770SHCR9V2+WsPUyQ/O8mM3KpOZKo3bZEjhCdok= 103 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 104 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 105 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 106 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 107 | github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= 108 | github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 109 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 110 | github.com/samber/lo v1.27.0 h1:GOyDWxsblvqYobqsmUuMddPa2/mMzkKyojlXol4+LaQ= 111 | github.com/samber/lo v1.27.0/go.mod h1:it33p9UtPMS7z72fP4gw/EIfQB2eI8ke7GR2wc6+Rhg= 112 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 113 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 114 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 115 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 116 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 117 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 118 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 119 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 120 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 121 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 122 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 123 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 124 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 125 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 126 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 127 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 128 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 129 | github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= 130 | github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= 131 | github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= 132 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 133 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 134 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 135 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= 136 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 137 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= 138 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 139 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 140 | golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs= 141 | golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= 142 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 143 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 144 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 145 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 146 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 147 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 148 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 152 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 153 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 154 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 155 | golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho= 156 | golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 158 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= 159 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= 160 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 161 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 162 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 163 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 164 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 167 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 168 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 169 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 170 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 171 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 172 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 173 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 174 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 175 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 176 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 177 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 178 | --------------------------------------------------------------------------------