0
0
mirror of https://github.com/thegeeklab/wp-git-action.git synced 2024-11-21 13:50:39 +00:00

refactor: rework batch command execution (#109)

This commit is contained in:
Robert Kaussow 2024-05-05 22:14:55 +02:00 committed by GitHub
parent 92a7c252d2
commit 179014e52e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1262 additions and 662 deletions

View File

@ -19,7 +19,6 @@ GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/cmd/golangci-lint@$(G
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
GOTESTSUM_PACKAGE ?= gotest.tools/gotestsum@latest GOTESTSUM_PACKAGE ?= gotest.tools/gotestsum@latest
GENERATE ?=
XGO_VERSION := go-1.22.x XGO_VERSION := go-1.22.x
XGO_TARGETS ?= linux/amd64,linux/arm-6,linux/arm-7,linux/arm64 XGO_TARGETS ?= linux/amd64,linux/arm-6,linux/arm-7,linux/arm64
@ -65,12 +64,7 @@ lint: golangci-lint
.PHONY: generate .PHONY: generate
generate: generate:
$(GO) generate $(GENERATE) $(GO) generate $(PACKAGES)
.PHONY: generate-docs
generate-docs:
$(GO) generate ./cmd/$(EXECUTABLE)/flags.go
.PHONY: test .PHONY: test
test: test:

View File

@ -1,58 +0,0 @@
//go:build generate
// +build generate
package main
import (
"bytes"
"embed"
"fmt"
"os"
"text/template"
"github.com/thegeeklab/wp-git-action/plugin"
"github.com/thegeeklab/wp-plugin-go/docs"
wp "github.com/thegeeklab/wp-plugin-go/plugin"
wp_template "github.com/thegeeklab/wp-plugin-go/template"
"github.com/urfave/cli/v2"
)
//go:embed templates/docs-data.yaml.tmpl
var yamlTemplate embed.FS
func main() {
settings := &plugin.Settings{}
app := &cli.App{
Flags: settingsFlags(settings, wp.FlagsPluginCategory),
}
out, err := toYAML(app)
if err != nil {
panic(err)
}
fi, err := os.Create("../../docs/data/data-raw.yaml")
if err != nil {
panic(err)
}
defer fi.Close()
if _, err := fi.WriteString(out); err != nil {
panic(err)
}
}
func toYAML(app *cli.App) (string, error) {
var w bytes.Buffer
yamlTmpl, err := template.New("docs").Funcs(wp_template.LoadFuncMap()).ParseFS(yamlTemplate, "templates/docs-data.yaml.tmpl")
if err != nil {
fmt.Println(yamlTmpl)
return "", err
}
if err := yamlTmpl.ExecuteTemplate(&w, "docs-data.yaml.tmpl", docs.GetTemplateData(app)); err != nil {
return "", err
}
return w.String(), nil
}

View File

@ -1,163 +0,0 @@
package main
import (
"github.com/thegeeklab/wp-git-action/plugin"
"github.com/urfave/cli/v2"
)
// settingsFlags has the cli.Flags for the plugin.Settings.
//
//go:generate go run docs.go flags.go
func settingsFlags(settings *plugin.Settings, category string) []cli.Flag {
return []cli.Flag{
&cli.StringSliceFlag{
Name: "action",
Usage: "git action to execute",
EnvVars: []string{"PLUGIN_ACTION"},
Destination: &settings.Action,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "author-name",
Usage: "git author name",
EnvVars: []string{"PLUGIN_AUTHOR_NAME", "CI_COMMIT_AUTHOR"},
Destination: &settings.Repo.Author.Name,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "author-email",
Usage: "git author email",
EnvVars: []string{"PLUGIN_AUTHOR_EMAIL", "CI_COMMIT_AUTHOR_EMAIL"},
Destination: &settings.Repo.Author.Email,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "netrc.machine",
Usage: "netrc remote machine name",
EnvVars: []string{"PLUGIN_NETRC_MACHINE", "CI_NETRC_MACHINE"},
Destination: &settings.Netrc.Machine,
Value: "github.com",
Category: category,
},
&cli.StringFlag{
Name: "netrc.username",
Usage: "netrc login user on the remote machine",
EnvVars: []string{"PLUGIN_NETRC_USERNAME", "CI_NETRC_USERNAME"},
Destination: &settings.Netrc.Login,
Value: "token",
Category: category,
},
&cli.StringFlag{
Name: "netrc.password",
Usage: "netrc login password on the remote machine",
EnvVars: []string{"PLUGIN_NETRC_PASSWORD", "CI_NETRC_PASSWORD"},
Destination: &settings.Netrc.Password,
Category: category,
},
&cli.StringFlag{
Name: "ssh-key",
Usage: "ssh private key for the remote repository",
EnvVars: []string{"PLUGIN_SSH_KEY"},
Destination: &settings.SSHKey,
Category: category,
},
&cli.StringFlag{
Name: "remote-url",
Usage: "url of the remote repository",
EnvVars: []string{"PLUGIN_REMOTE_URL", "CI_REPO_CLONE_URL"},
Destination: &settings.Repo.RemoteURL,
Category: category,
},
&cli.StringFlag{
Name: "branch",
Usage: "name of the git source branch",
EnvVars: []string{"PLUGIN_BRANCH"},
Destination: &settings.Repo.Branch,
Value: "main",
Category: category,
},
&cli.StringFlag{
Name: "path",
Usage: "path to clone git repository",
EnvVars: []string{"PLUGIN_PATH"},
Destination: &settings.Repo.WorkDir,
Category: category,
},
&cli.StringFlag{
Name: "commit-message",
Usage: "commit message",
EnvVars: []string{"PLUGIN_MESSAGE"},
Destination: &settings.Repo.CommitMsg,
Value: "[skip ci] commit dirty state",
Category: category,
},
&cli.BoolFlag{
Name: "force-push",
Usage: "enable force push to remote repository",
EnvVars: []string{"PLUGIN_FORCE"},
Destination: &settings.Repo.ForcePush,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "followtags",
Usage: "follow tags for pushes to remote repository",
EnvVars: []string{"PLUGIN_FOLLOWTAGS"},
Destination: &settings.Repo.PushFollowTags,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "insecure-skip-ssl-verify",
Usage: "skip ssl verification of the remote machine",
EnvVars: []string{"PLUGIN_INSECURE_SKIP_SSL_VERIFY"},
Destination: &settings.Repo.InsecureSkipSSLVerify,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "empty-commit",
Usage: "allow empty commits",
EnvVars: []string{"PLUGIN_EMPTY_COMMIT"},
Destination: &settings.Repo.EmptyCommit,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "bypass the pre-commit and commit-msg hooks",
EnvVars: []string{"PLUGIN_NO_VERIFY"},
Destination: &settings.Repo.NoVerify,
Value: false,
Category: category,
},
&cli.StringFlag{
Name: "pages.directory",
Usage: "source directory to be synchronized with the pages banch",
EnvVars: []string{"PLUGIN_PAGES_DIRECTORY"},
Destination: &settings.Pages.Directory,
Value: "docs/",
Category: category,
},
&cli.StringSliceFlag{
Name: "pages.exclude",
Usage: "files or directories to exclude from the pages rsync command",
EnvVars: []string{"PLUGIN_PAGES_EXCLUDE"},
Destination: &settings.Pages.Exclude,
Category: category,
},
&cli.BoolFlag{
Name: "pages.delete",
Usage: "add delete flag to pages rsync command",
EnvVars: []string{"PLUGIN_PAGES_DELETE"},
Destination: &settings.Pages.Delete,
Value: true,
Category: category,
},
}
}

View File

@ -1,10 +1,7 @@
package main package main
import ( import (
"fmt"
"github.com/thegeeklab/wp-git-action/plugin" "github.com/thegeeklab/wp-git-action/plugin"
wp "github.com/thegeeklab/wp-plugin-go/plugin"
) )
//nolint:gochecknoglobals //nolint:gochecknoglobals
@ -14,14 +11,5 @@ var (
) )
func main() { func main() {
settings := &plugin.Settings{} plugin.New(nil, BuildVersion, BuildDate).Run()
options := wp.Options{
Name: "wp-git-action",
Description: "Perform git actions.",
Version: BuildVersion,
VersionMetadata: fmt.Sprintf("date=%s", BuildDate),
Flags: settingsFlags(settings, wp.FlagsPluginCategory),
}
plugin.New(options, settings).Run()
} }

View File

@ -1,18 +0,0 @@
---
{{- if .GlobalArgs }}
properties:
{{- range $v := .GlobalArgs }}
- name: {{ $v.Name }}
{{- with $v.Description }}
description: |
{{ . | ToSentence }}
{{- end }}
{{- with $v.Type }}
type: {{ . }}
{{- end }}
{{- with $v.Default }}
defaultValue: {{ . }}
{{- end }}
required: {{ default false $v.Required }}
{{ end -}}
{{ end -}}

View File

@ -60,15 +60,22 @@ properties:
defaultValue: false defaultValue: false
required: false required: false
- name: insecure_skip_ssl_verify - name: insecure_skip_verify
description: | description: |
Skip ssl verification of the remote machine. Skip SSL verification.
Activating this option is insecure and should be avoided in most cases. Activating this option is insecure and should be avoided in most cases.
type: bool type: bool
defaultValue: false defaultValue: false
required: false required: false
- name: log_level
description: |
Plugin log level.
type: string
defaultValue: "info"
required: false
- name: message - name: message
description: | description: |
Commit message. Commit message.

View File

@ -2,43 +2,39 @@ package git
import ( import (
"fmt" "fmt"
"os"
"github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
// FetchSource fetches the source from remote. // FetchSource fetches the source from remote.
func FetchSource(repo Repository) *execabs.Cmd { func FetchSource(repo Repository) *types.Cmd {
args := []string{ args := []string{
"fetch", "fetch",
"origin", "origin",
fmt.Sprintf("+%s:", repo.Branch), fmt.Sprintf("+%s:", repo.Branch),
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// CheckoutHead handles branch checkout. // CheckoutHead handles branch checkout.
func CheckoutHead(repo Repository) *execabs.Cmd { func CheckoutHead(repo Repository) *types.Cmd {
args := []string{ args := []string{
"checkout", "checkout",
"-qf", "-qf",
repo.Branch, repo.Branch,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }

73
git/clone_test.go Normal file
View File

@ -0,0 +1,73 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestFetchSource(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "fetch from origin with branch",
repo: Repository{
WorkDir: "/path/to/repo",
Branch: "main",
},
want: []string{gitBin, "fetch", "origin", "+main:"},
},
{
name: "fetch from origin with different branch",
repo: Repository{
WorkDir: "/path/to/repo",
Branch: "develop",
},
want: []string{gitBin, "fetch", "origin", "+develop:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := FetchSource(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestCheckoutHead(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "checkout head with branch",
repo: Repository{
WorkDir: "/path/to/repo",
Branch: "main",
},
want: []string{gitBin, "checkout", "-qf", "main"},
},
{
name: "checkout head with different branch",
repo: Repository{
WorkDir: "/path/to/repo",
Branch: "develop",
},
want: []string{gitBin, "checkout", "-qf", "develop"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := CheckoutHead(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}

View File

@ -1,13 +1,12 @@
package git package git
import ( import (
"os" "github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
// ForceAdd forces the addition of all dirty files. // ForceAdd forces the addition of all dirty files.
func ForceAdd(repo Repository) *execabs.Cmd { func ForceAdd(repo Repository) *types.Cmd {
cmd := execabs.Command( cmd := execabs.Command(
gitBin, gitBin,
"add", "add",
@ -15,19 +14,19 @@ func ForceAdd(repo Repository) *execabs.Cmd {
"--force", "--force",
) )
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// Add updates the index to match the working tree. // Add updates the index to match the working tree.
func Add(repo Repository) *execabs.Cmd { func Add(repo Repository) *types.Cmd {
cmd := execabs.Command( cmd := execabs.Command(
gitBin, gitBin,
"add", "add",
) )
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
if repo.Add != "" { if repo.Add != "" {
cmd.Args = append(cmd.Args, repo.Add) cmd.Args = append(cmd.Args, repo.Add)
@ -35,11 +34,13 @@ func Add(repo Repository) *execabs.Cmd {
cmd.Args = append(cmd.Args, "--all") cmd.Args = append(cmd.Args, "--all")
} }
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// TestCleanTree returns non-zero if diff between index and local repository. // TestCleanTree returns non-zero if diff between index and local repository.
func TestCleanTree(repo Repository) *execabs.Cmd { func IsCleanTree(repo Repository) *types.Cmd {
cmd := execabs.Command( cmd := execabs.Command(
gitBin, gitBin,
"diff-index", "diff-index",
@ -48,13 +49,14 @@ func TestCleanTree(repo Repository) *execabs.Cmd {
"--ignore-submodules", "--ignore-submodules",
) )
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// EmptyCommit simply create an empty commit. // EmptyCommit simply create an empty commit.
func EmptyCommit(repo Repository) *execabs.Cmd { func EmptyCommit(repo Repository) *types.Cmd {
args := []string{ args := []string{
"commit", "commit",
"--allow-empty", "--allow-empty",
@ -62,38 +64,33 @@ func EmptyCommit(repo Repository) *execabs.Cmd {
repo.CommitMsg, repo.CommitMsg,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
if repo.NoVerify { if repo.NoVerify {
cmd.Args = append(cmd.Args, "--no-verify") cmd.Args = append(cmd.Args, "--no-verify")
} }
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// ForceCommit commits every change while skipping CI. func Commit(repo Repository) *types.Cmd {
func ForceCommit(repo Repository) *execabs.Cmd {
args := []string{ args := []string{
"commit", "commit",
"-m", "-m",
repo.CommitMsg, repo.CommitMsg,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
if repo.NoVerify { if repo.NoVerify {
cmd.Args = append(cmd.Args, "--no-verify") cmd.Args = append(cmd.Args, "--no-verify")
} }
return cmd return &types.Cmd{
Cmd: cmd,
}
} }

105
git/commit_test.go Normal file
View File

@ -0,0 +1,105 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAdd(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "add all files",
repo: Repository{
WorkDir: "/path/to/repo",
Add: "",
},
want: []string{gitBin, "add", "--all"},
},
{
name: "add specific file",
repo: Repository{
WorkDir: "/path/to/repo",
Add: "file.go",
},
want: []string{gitBin, "add", "file.go"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := Add(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestIsCleanTree(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "clean working tree",
repo: Repository{
WorkDir: "/path/to/repo",
},
want: []string{gitBin, "diff-index", "--quiet", "HEAD", "--ignore-submodules"},
},
{
name: "unclean working tree",
repo: Repository{
WorkDir: "/path/to/unclean/repo",
},
want: []string{gitBin, "diff-index", "--quiet", "HEAD", "--ignore-submodules"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := IsCleanTree(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestEmptyCommit(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "empty commit with default options",
repo: Repository{
WorkDir: "/path/to/repo",
CommitMsg: "Empty commit",
},
want: []string{gitBin, "commit", "--allow-empty", "-m", "Empty commit"},
},
{
name: "empty commit with no-verify option",
repo: Repository{
WorkDir: "/path/to/repo",
CommitMsg: "Empty commit",
NoVerify: true,
},
want: []string{gitBin, "commit", "--allow-empty", "-m", "Empty commit", "--no-verify"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := EmptyCommit(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}

View File

@ -1,14 +1,15 @@
package git package git
import ( import (
"os"
"strconv" "strconv"
"github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
// repoUserEmail sets the global git author email. // ConfigAutocorrect sets the local git autocorrect configuration for the given repository.
func ConfigAutocorrect(repo Repository) *execabs.Cmd { // The autocorrect setting determines how git handles minor typos in commands.
func ConfigAutocorrect(repo Repository) *types.Cmd {
args := []string{ args := []string{
"config", "config",
"--local", "--local",
@ -16,18 +17,16 @@ func ConfigAutocorrect(repo Repository) *execabs.Cmd {
repo.Autocorrect, repo.Autocorrect,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// repoUserEmail sets the global git author email. // ConfigUserEmail sets the global git author email.
func ConfigUserEmail(repo Repository) *execabs.Cmd { func ConfigUserEmail(repo Repository) *types.Cmd {
args := []string{ args := []string{
"config", "config",
"--local", "--local",
@ -35,18 +34,16 @@ func ConfigUserEmail(repo Repository) *execabs.Cmd {
repo.Author.Email, repo.Author.Email,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// repoUserName sets the global git author name. // ConfigUserName configures the user.name git config setting for the given repository.
func ConfigUserName(repo Repository) *execabs.Cmd { func ConfigUserName(repo Repository) *types.Cmd {
args := []string{ args := []string{
"config", "config",
"--local", "--local",
@ -54,31 +51,27 @@ func ConfigUserName(repo Repository) *execabs.Cmd {
repo.Author.Name, repo.Author.Name,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// ConfigSSLVerify disables globally the git ssl verification. // ConfigSSLVerify configures the http.sslVerify git config setting for the given repository.
func ConfigSSLVerify(repo Repository) *execabs.Cmd { func ConfigSSLVerify(repo Repository, skipVerify bool) *types.Cmd {
args := []string{ args := []string{
"config", "config",
"--local", "--local",
"http.sslVerify", "http.sslVerify",
strconv.FormatBool(!repo.InsecureSkipSSLVerify), strconv.FormatBool(!skipVerify),
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }

124
git/config_test.go Normal file
View File

@ -0,0 +1,124 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestConfigAutocorrect(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "enable autocorrect",
repo: Repository{
WorkDir: "/path/to/repo",
Autocorrect: "1",
},
want: []string{gitBin, "config", "--local", "help.autocorrect", "1"},
},
{
name: "disable autocorrect",
repo: Repository{
WorkDir: "/path/to/repo",
Autocorrect: "0",
},
want: []string{gitBin, "config", "--local", "help.autocorrect", "0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := ConfigAutocorrect(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestConfigUserEmail(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "set user email",
repo: Repository{
WorkDir: "/path/to/repo",
Author: Author{
Email: "user@example.com",
},
},
want: []string{gitBin, "config", "--local", "user.email", "user@example.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := ConfigUserEmail(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestConfigUserName(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "set user name",
repo: Repository{
WorkDir: "/path/to/repo",
Author: Author{
Name: "John Doe",
},
},
want: []string{gitBin, "config", "--local", "user.name", "John Doe"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := ConfigUserName(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestConfigSSLVerify(t *testing.T) {
tests := []struct {
name string
repo Repository
skipVerify bool
want []string
}{
{
name: "enable SSL verification",
repo: Repository{WorkDir: "/path/to/repo"},
skipVerify: false,
want: []string{gitBin, "config", "--local", "http.sslVerify", "true"},
},
{
name: "disable SSL verification",
repo: Repository{WorkDir: "/path/to/repo"},
skipVerify: true,
want: []string{gitBin, "config", "--local", "http.sslVerify", "false"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := ConfigSSLVerify(tt.repo, tt.skipVerify)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}

View File

@ -1,19 +1,19 @@
package git package git
import ( import (
"os" "github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
// RemoteRemove drops the defined remote from a git repo. // Init creates a new Git repository in the specified directory.
func Init(repo Repository) *execabs.Cmd { func Init(repo Repository) *types.Cmd {
cmd := execabs.Command( cmd := execabs.Command(
gitBin, gitBin,
"init", "init",
) )
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }

23
git/init_test.go Normal file
View File

@ -0,0 +1,23 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestInit(t *testing.T) {
repo := Repository{
WorkDir: "/path/to/repo",
}
cmd := Init(repo)
require.Equal(t, []string{gitBin, "init"}, cmd.Cmd.Args)
require.Equal(t, repo.WorkDir, cmd.Cmd.Dir)
// Test with an empty work directory
repo.WorkDir = ""
cmd = Init(repo)
require.Equal(t, []string{gitBin, "init"}, cmd.Cmd.Args)
require.Empty(t, cmd.Cmd.Dir)
}

View File

@ -2,31 +2,29 @@ package git
import ( import (
"fmt" "fmt"
"os"
"github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
// RemoteRemove drops the defined remote from a git repo. // RemoteRemove drops the defined remote from a git repo.
func RemoteRemove(repo Repository) *execabs.Cmd { func RemoteRemove(repo Repository) *types.Cmd {
args := []string{ args := []string{
"remote", "remote",
"rm", "rm",
repo.RemoteName, repo.RemoteName,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// RemoteAdd adds an additional remote to a git repo. // RemoteAdd adds an additional remote to a git repo.
func RemoteAdd(repo Repository) *execabs.Cmd { func RemoteAdd(repo Repository) *types.Cmd {
args := []string{ args := []string{
"remote", "remote",
"add", "add",
@ -34,43 +32,34 @@ func RemoteAdd(repo Repository) *execabs.Cmd {
repo.RemoteURL, repo.RemoteURL,
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// RemotePush pushs the changes from the local head to a remote branch. // RemotePush pushs the changes from the local head to a remote branch.
func RemotePush(repo Repository) *execabs.Cmd { func RemotePush(repo Repository) *types.Cmd {
args := []string{ args := []string{
"push", "push",
repo.RemoteName, repo.RemoteName,
fmt.Sprintf("HEAD:%s", repo.Branch), fmt.Sprintf("HEAD:%s", repo.Branch),
} }
cmd := execabs.Command( cmd := execabs.Command(gitBin, args...)
gitBin,
args...,
)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
if repo.ForcePush { if repo.ForcePush {
cmd.Args = append( cmd.Args = append(cmd.Args, "--force")
cmd.Args,
"--force",
)
} }
if repo.PushFollowTags { if repo.PushFollowTags {
cmd.Args = append( cmd.Args = append(cmd.Args, "--follow-tags")
cmd.Args,
"--follow-tags")
} }
return cmd return &types.Cmd{
Cmd: cmd,
}
} }

139
git/remote_test.go Normal file
View File

@ -0,0 +1,139 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRemoteRemove(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "remove remote",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "origin",
},
want: []string{gitBin, "remote", "rm", "origin"},
},
{
name: "remove custom remote name",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "upstream",
},
want: []string{gitBin, "remote", "rm", "upstream"},
},
{
name: "remove remote with empty work dir",
repo: Repository{
RemoteName: "origin",
},
want: []string{gitBin, "remote", "rm", "origin"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RemoteRemove(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestRemoteAdd(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "add remote with valid inputs",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "origin",
RemoteURL: "https://example.com/repo.git",
},
want: []string{gitBin, "remote", "add", "origin", "https://example.com/repo.git"},
},
{
name: "add remote with empty work dir",
repo: Repository{
RemoteName: "origin",
RemoteURL: "https://example.com/repo.git",
},
want: []string{gitBin, "remote", "add", "origin", "https://example.com/repo.git"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RemoteAdd(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestRemotePush(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
forcePush bool
followTags bool
}{
{
name: "push with default options",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "origin",
Branch: "main",
},
want: []string{gitBin, "push", "origin", "HEAD:main"},
},
{
name: "push with force option",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "origin",
Branch: "main",
ForcePush: true,
},
want: []string{gitBin, "push", "origin", "HEAD:main", "--force"},
forcePush: true,
},
{
name: "push with follow tags option",
repo: Repository{
WorkDir: "/path/to/repo",
RemoteName: "origin",
Branch: "main",
PushFollowTags: true,
},
want: []string{gitBin, "push", "origin", "HEAD:main", "--follow-tags"},
followTags: true,
},
{
name: "push with empty work dir",
repo: Repository{
RemoteName: "origin",
Branch: "main",
},
want: []string{gitBin, "push", "origin", "HEAD:main"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RemotePush(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}

View File

@ -1,41 +1,40 @@
package git package git
import ( import (
"bytes"
"os"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
func Status(repo Repository) *execabs.Cmd { // Status returns a command that runs `git status --porcelain` for the given repository.
func Status(repo Repository) *types.Cmd {
cmd := execabs.Command( cmd := execabs.Command(
gitBin, gitBin,
"status", "status",
"--porcelain", "--porcelain",
) )
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
return cmd return &types.Cmd{
Cmd: cmd,
}
} }
// IsDirty checks if the given repository has any uncommitted changes.
// It runs `git status --porcelain` and returns true if the output is non-empty,
// indicating that there are uncommitted changes in the repository.
// If there is an error running the git command, it returns false.
func IsDirty(repo Repository) bool { func IsDirty(repo Repository) bool {
res := bytes.NewBufferString("")
cmd := Status(repo) cmd := Status(repo)
cmd.Dir = repo.WorkDir cmd.Dir = repo.WorkDir
cmd.Stderr = os.Stderr
cmd.Stdout = res
cmd.Stderr = res
err := runCommand(cmd) out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return false return false
} }
if res.Len() > 0 { if len(out) > 0 {
log.Debug().Msg(res.String()) log.Debug().Msg(string(out))
return true return true
} }

84
git/status_test.go Normal file
View File

@ -0,0 +1,84 @@
package git
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestStatus(t *testing.T) {
tests := []struct {
name string
repo Repository
want []string
}{
{
name: "with work dir",
repo: Repository{
WorkDir: "/path/to/repo",
},
want: []string{gitBin, "status", "--porcelain"},
},
{
name: "without work dir",
repo: Repository{},
want: []string{gitBin, "status", "--porcelain"},
},
{
name: "with custom stderr",
repo: Repository{
WorkDir: "/path/to/repo",
},
want: []string{gitBin, "status", "--porcelain"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := Status(tt.repo)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.repo.WorkDir, cmd.Cmd.Dir)
})
}
}
func TestIsDirty(t *testing.T) {
tests := []struct {
name string
repo Repository
want bool
}{
{
name: "dirty repo",
repo: Repository{
WorkDir: t.TempDir(),
},
want: true,
},
{
name: "clean repo",
repo: Repository{
WorkDir: t.TempDir(),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Init(tt.repo).Run(); err != nil {
require.NoError(t, err)
}
if tt.want {
_, err := os.Create(filepath.Join(tt.repo.WorkDir, "dummy"))
require.NoError(t, err)
}
isDirty := IsDirty(tt.repo)
require.Equal(t, tt.want, isDirty)
})
}
}

View File

@ -17,12 +17,11 @@ type Repository struct {
Autocorrect string Autocorrect string
NoVerify bool NoVerify bool
InsecureSkipSSLVerify bool
EmptyCommit bool EmptyCommit bool
PushFollowTags bool PushFollowTags bool
ForcePush bool ForcePush bool
WorkDir string WorkDir string
InitExists bool IsEmpty bool
Author Author Author Author
} }

56
git/util.go Normal file
View File

@ -0,0 +1,56 @@
package git
import (
"fmt"
"os"
"path/filepath"
)
const (
netrcFile = `machine %s
login %s
password %s
`
configFile = `Host *
StrictHostKeyChecking no
UserKnownHostsFile=/dev/null
`
)
const (
strictFilePerm = 0o600
strictDirPerm = 0o700
)
// WriteKey writes the SSH private key.
func WriteSSHKey(path, key string) error {
sshPath := filepath.Join(path, ".ssh")
confPath := filepath.Join(sshPath, "config")
keyPath := filepath.Join(sshPath, "id_rsa")
if err := os.MkdirAll(sshPath, strictDirPerm); err != nil {
return fmt.Errorf("failed to create .ssh directory: %w", err)
}
if err := os.WriteFile(confPath, []byte(configFile), strictFilePerm); err != nil {
return fmt.Errorf("failed to create .ssh/config file: %w", err)
}
if err := os.WriteFile(keyPath, []byte(key), strictFilePerm); err != nil {
return fmt.Errorf("failed to create .ssh/id_rsa file: %w", err)
}
return nil
}
// WriteNetrc writes the netrc file.
func WriteNetrc(path, machine, login, password string) error {
netrcPath := filepath.Join(path, ".netrc")
netrcContent := fmt.Sprintf(netrcFile, machine, login, password)
if err := os.WriteFile(netrcPath, []byte(netrcContent), strictFilePerm); err != nil {
return fmt.Errorf("failed to create .netrc file: %w", err)
}
return nil
}

96
git/util_test.go Normal file
View File

@ -0,0 +1,96 @@
package git
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestWriteSSHKey(t *testing.T) {
tests := []struct {
name string
privateKey string
dir string
wantErr bool
}{
{
name: "valid private key",
privateKey: "valid_private_key",
dir: t.TempDir(),
wantErr: false,
},
{
name: "empty private key",
privateKey: "",
dir: t.TempDir(),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WriteSSHKey(tt.dir, tt.privateKey)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
privateKeyPath := filepath.Join(tt.dir, ".ssh", "id_rsa")
_, err = os.Stat(privateKeyPath)
require.NoError(t, err)
configPath := filepath.Join(tt.dir, ".ssh", "config")
_, err = os.Stat(configPath)
require.NoError(t, err)
})
}
}
func TestWriteNetrc(t *testing.T) {
tests := []struct {
name string
path string
machine string
login string
password string
wantErr bool
}{
{
name: "valid input",
path: t.TempDir(),
machine: "example.com",
login: "user",
password: "pass",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WriteNetrc(tt.path, tt.machine, tt.login, tt.password)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
netrcPath := filepath.Join(tt.path, ".netrc")
_, err = os.Stat(netrcPath)
require.NoError(t, err)
content, err := os.ReadFile(netrcPath)
require.NoError(t, err)
expected := fmt.Sprintf("machine %s\nlogin %s\npassword %s\n", tt.machine, tt.login, tt.password)
require.Equal(t, expected, string(content))
})
}
}

View File

@ -1,103 +0,0 @@
package git
import (
"fmt"
"os"
"os/user"
"path/filepath"
"github.com/thegeeklab/wp-plugin-go/trace"
"golang.org/x/sys/execabs"
)
const (
netrcFile = `
machine %s
login %s
password %s
`
configFile = `
Host *
StrictHostKeyChecking no
UserKnownHostsFile=/dev/null
`
)
const (
strictFilePerm = 0o600
strictDirPerm = 0o600
)
// WriteKey writes the SSH private key.
func WriteSSHKey(privateKey string) error {
home := "/root"
if currentUser, err := user.Current(); err == nil {
home = currentUser.HomeDir
}
sshpath := filepath.Join(home, ".ssh")
if err := os.MkdirAll(sshpath, strictDirPerm); err != nil {
return err
}
confpath := filepath.Join(sshpath, "config")
if err := os.WriteFile(
confpath,
[]byte(configFile),
strictFilePerm,
); err != nil {
return err
}
privpath := filepath.Join(sshpath, "id_rsa")
return os.WriteFile(
privpath,
[]byte(privateKey),
strictFilePerm,
)
}
// WriteNetrc writes the netrc file.
func WriteNetrc(machine, login, password string) error {
netrcContent := fmt.Sprintf(
netrcFile,
machine,
login,
password,
)
home := "/root"
if currentUser, err := user.Current(); err == nil {
home = currentUser.HomeDir
}
netpath := filepath.Join(
home,
".netrc",
)
return os.WriteFile(
netpath,
[]byte(netrcContent),
strictFilePerm,
)
}
func runCommand(cmd *execabs.Cmd) error {
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}
trace.Cmd(cmd)
return cmd.Run()
}

8
go.mod
View File

@ -4,9 +4,10 @@ go 1.22
require ( require (
github.com/rs/zerolog v1.32.0 github.com/rs/zerolog v1.32.0
github.com/thegeeklab/wp-plugin-go v1.7.1 github.com/stretchr/testify v1.9.0
github.com/thegeeklab/wp-plugin-go/v2 v2.2.0
github.com/urfave/cli/v2 v2.27.2 github.com/urfave/cli/v2 v2.27.2
golang.org/x/sys v0.19.0 golang.org/x/sys v0.20.0
) )
require ( require (
@ -14,6 +15,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.1.1 // indirect github.com/google/uuid v1.1.1 // indirect
github.com/huandu/xstrings v1.3.3 // indirect github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.11 // indirect github.com/imdario/mergo v0.3.11 // indirect
@ -22,10 +24,12 @@ require (
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cast v1.3.1 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
golang.org/x/crypto v0.22.0 // indirect golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

9
go.sum
View File

@ -46,8 +46,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/thegeeklab/wp-plugin-go v1.7.1 h1:zfR/rfNPuyVhXJu1fsLfp4+Mz2pTf6WwW/mIqw9750I= github.com/thegeeklab/wp-plugin-go/v2 v2.2.0 h1:Z6UzL8N0v3J2uuk67DBnH19QNV1vXihaKC2OoH2TMAY=
github.com/thegeeklab/wp-plugin-go v1.7.1/go.mod h1:Ixi5plt9tpFGTu6yc/Inm5DcDpp3xPTeohfr86gf2EU= github.com/thegeeklab/wp-plugin-go/v2 v2.2.0/go.mod h1:I/3M/4OPvr4FFS+s0aaImpX1llA/lS2KC6Bnp+qzsCs=
github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
@ -76,8 +76,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@ -89,6 +89,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=

42
internal/doc/main.go Normal file
View File

@ -0,0 +1,42 @@
//go:build generate
// +build generate
package main
import (
"context"
"flag"
"net/http"
"os"
"time"
"github.com/thegeeklab/wp-git-action/plugin"
"github.com/thegeeklab/wp-plugin-go/v2/docs"
wp_template "github.com/thegeeklab/wp-plugin-go/v2/template"
)
func main() {
tmpl := "https://raw.githubusercontent.com/thegeeklab/woodpecker-plugins/main/templates/docs-data.yaml.tmpl"
client := http.Client{
Timeout: 30 * time.Second,
}
p := plugin.New(nil)
out, err := wp_template.Render(context.Background(), client, tmpl, docs.GetTemplateData(p.App))
if err != nil {
panic(err)
}
outputFile := flag.String("output", "", "Output file path")
flag.Parse()
if *outputFile == "" {
panic("no output file specified")
}
err = os.WriteFile(*outputFile, []byte(out), 0o644)
if err != nil {
panic(err)
}
}

View File

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"github.com/thegeeklab/wp-git-action/git" "github.com/thegeeklab/wp-git-action/git"
"github.com/thegeeklab/wp-plugin-go/v2/file"
"github.com/thegeeklab/wp-plugin-go/v2/types"
) )
var ( var (
@ -20,6 +22,13 @@ var (
ErrGitCloneDestintionNotValid = errors.New("destination not valid") ErrGitCloneDestintionNotValid = errors.New("destination not valid")
) )
const (
ActionClone Action = "clone"
ActionCommit Action = "commit"
ActionPush Action = "push"
ActionPages Action = "pages"
)
//nolint:revive //nolint:revive
func (p *Plugin) run(ctx context.Context) error { func (p *Plugin) run(ctx context.Context) error {
if err := p.Validate(); err != nil { if err := p.Validate(); err != nil {
@ -46,20 +55,21 @@ func (p *Plugin) Validate() error {
} }
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get working directory: %w", err)
} }
for _, action := range p.Settings.Action.Value() { for _, actionStr := range p.Settings.Action.Value() {
action := Action(actionStr)
switch action { switch action {
case "clone": case ActionClone:
continue continue
case "commit": case ActionCommit:
continue continue
case "push": case ActionPush:
if p.Settings.SSHKey == "" && p.Settings.Netrc.Password == "" { if p.Settings.SSHKey == "" && p.Settings.Netrc.Password == "" {
return ErrAuthSourceNotSet return ErrAuthSourceNotSet
} }
case "pages": case ActionPages:
p.Settings.Pages.Directory = filepath.Join(p.Settings.Repo.WorkDir, p.Settings.Pages.Directory) p.Settings.Pages.Directory = filepath.Join(p.Settings.Repo.WorkDir, p.Settings.Pages.Directory)
p.Settings.Repo.WorkDir = filepath.Join(p.Settings.Repo.WorkDir, ".tmp") p.Settings.Repo.WorkDir = filepath.Join(p.Settings.Repo.WorkDir, ".tmp")
@ -83,7 +93,7 @@ func (p *Plugin) Validate() error {
return ErrPagesActionNotExclusive return ErrPagesActionNotExclusive
} }
default: default:
return fmt.Errorf("%w: %s", ErrActionUnknown, action) return fmt.Errorf("%w: %s", ErrActionUnknown, actionStr)
} }
} }
@ -92,6 +102,8 @@ func (p *Plugin) Validate() error {
// Execute provides the implementation of the plugin. // Execute provides the implementation of the plugin.
func (p *Plugin) Execute() error { func (p *Plugin) Execute() error {
homeDir := getUserHomeDir()
batchCmd := make([]*types.Cmd, 0)
gitEnv := []string{ gitEnv := []string{
"GIT_AUTHOR_NAME", "GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL", "GIT_AUTHOR_EMAIL",
@ -103,147 +115,150 @@ func (p *Plugin) Execute() error {
for _, env := range gitEnv { for _, env := range gitEnv {
if err := os.Unsetenv(env); err != nil { if err := os.Unsetenv(env); err != nil {
return err return fmt.Errorf("failed to unset git env vars '%s': %w", env, err)
} }
} }
if err := os.Setenv("GIT_TERMINAL_PROMPT", "0"); err != nil { if err := os.Setenv("GIT_TERMINAL_PROMPT", "0"); err != nil {
return err return fmt.Errorf("failed to git env var': %w", err)
}
if err := p.handleInit(); err != nil {
return err
}
if err := git.ConfigAutocorrect(p.Settings.Repo).Run(); err != nil {
return err
}
if err := git.ConfigUserName(p.Settings.Repo).Run(); err != nil {
return err
}
if err := git.ConfigUserEmail(p.Settings.Repo).Run(); err != nil {
return err
}
if err := git.ConfigSSLVerify(p.Settings.Repo).Run(); err != nil {
return err
} }
// Write SSH key and netrc file.
if p.Settings.SSHKey != "" { if p.Settings.SSHKey != "" {
if err := git.WriteSSHKey(p.Settings.SSHKey); err != nil { if err := git.WriteSSHKey(homeDir, p.Settings.SSHKey); err != nil {
return err return err
} }
} }
if err := git.WriteNetrc(p.Settings.Netrc.Machine, p.Settings.Netrc.Login, p.Settings.Netrc.Password); err != nil { netrc := p.Settings.Netrc
if err := git.WriteNetrc(homeDir, netrc.Machine, netrc.Login, netrc.Password); err != nil {
return err return err
} }
for _, action := range p.Settings.Action.Value() { // Handle repo initialization.
switch action {
case "clone":
if err := p.handleClone(); err != nil {
return err
}
case "commit":
if err := p.handleCommit(); err != nil {
return err
}
case "push":
if err := p.handlePush(); err != nil {
return err
}
case "pages":
if err := p.handlePages(); err != nil {
return err
}
}
}
return nil
}
// handleInit initializes the repository.
func (p *Plugin) handleInit() error {
path := filepath.Join(p.Settings.Repo.WorkDir, ".git")
if err := os.MkdirAll(p.Settings.Repo.WorkDir, os.ModePerm); err != nil { if err := os.MkdirAll(p.Settings.Repo.WorkDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create working directory: %w", err)
}
isEmpty, err := file.IsDirEmpty(p.Settings.Repo.WorkDir)
if err != nil {
return fmt.Errorf("failed to check working directory: %w", err)
}
p.Settings.Repo.IsEmpty = isEmpty
isDir, err := file.IsDir(filepath.Join(p.Settings.Repo.WorkDir, ".git"))
if err != nil {
return fmt.Errorf("failed to check working directory: %w", err)
}
if !isDir {
batchCmd = append(batchCmd, git.Init(p.Settings.Repo))
}
// Handle repo configuration.
batchCmd = append(batchCmd, git.ConfigAutocorrect(p.Settings.Repo))
batchCmd = append(batchCmd, git.ConfigUserName(p.Settings.Repo))
batchCmd = append(batchCmd, git.ConfigUserEmail(p.Settings.Repo))
batchCmd = append(batchCmd, git.ConfigSSLVerify(p.Settings.Repo, p.Network.InsecureSkipVerify))
for _, actionStr := range p.Settings.Action.Value() {
action := Action(actionStr)
switch action {
case ActionClone:
cmds, err := p.handleClone()
if err != nil {
return err return err
} }
if _, err := os.Stat(path); !os.IsNotExist(err) { batchCmd = append(batchCmd, cmds...)
p.Settings.Repo.InitExists = true case ActionCommit:
batchCmd = append(batchCmd, p.handleCommit()...)
return nil case ActionPush:
batchCmd = append(batchCmd, p.handlePush()...)
case ActionPages:
cmds, err := p.handlePages()
if err != nil {
return err
} }
return execute(git.Init(p.Settings.Repo)) batchCmd = append(batchCmd, cmds...)
}
}
for _, cmd := range batchCmd {
if err := cmd.Run(); err != nil {
return err
}
}
return nil
} }
// HandleClone clones remote. // handleClone clones the remote repository into the configured working directory.
func (p *Plugin) handleClone() error { // If the working directory is not empty, it returns an error.
if p.Settings.Repo.InitExists { func (p *Plugin) handleClone() ([]*types.Cmd, error) {
return fmt.Errorf("%w: %s exists and not empty", ErrGitCloneDestintionNotValid, p.Settings.Repo.WorkDir) var cmds []*types.Cmd
if !p.Settings.Repo.IsEmpty {
return cmds, fmt.Errorf("%w: %s exists and not empty", ErrGitCloneDestintionNotValid, p.Settings.Repo.WorkDir)
} }
if p.Settings.Repo.RemoteURL != "" { if p.Settings.Repo.RemoteURL != "" {
if err := execute(git.RemoteAdd(p.Settings.Repo)); err != nil { cmds = append(cmds, git.RemoteAdd(p.Settings.Repo))
return err
}
} }
if err := execute(git.FetchSource(p.Settings.Repo)); err != nil { cmds = append(cmds, git.FetchSource(p.Settings.Repo))
return err cmds = append(cmds, git.CheckoutHead(p.Settings.Repo))
}
return execute(git.CheckoutHead(p.Settings.Repo)) return cmds, nil
} }
// HandleCommit commits changes locally. // HandleCommit commits changes locally.
func (p *Plugin) handleCommit() error { func (p *Plugin) handleCommit() []*types.Cmd {
if err := execute(git.Add(p.Settings.Repo)); err != nil { var cmds []*types.Cmd
return err
}
if err := execute(git.TestCleanTree(p.Settings.Repo)); err != nil { cmds = append(cmds, git.Add(p.Settings.Repo))
if err := execute(git.ForceCommit(p.Settings.Repo)); err != nil {
return err if err := git.IsCleanTree(p.Settings.Repo).Run(); err != nil {
} cmds = append(cmds, git.Commit(p.Settings.Repo))
} }
if p.Settings.Repo.EmptyCommit { if p.Settings.Repo.EmptyCommit {
if err := execute(git.EmptyCommit(p.Settings.Repo)); err != nil { cmds = append(cmds, git.EmptyCommit(p.Settings.Repo))
return err
}
} }
return nil return cmds
} }
// HandlePush pushs changes to remote. // HandlePush pushs changes to remote.
func (p *Plugin) handlePush() error { func (p *Plugin) handlePush() []*types.Cmd {
return execute(git.RemotePush(p.Settings.Repo)) return []*types.Cmd{git.RemotePush(p.Settings.Repo)}
} }
// HandlePages syncs, commits and pushes the changes from the pages directory to the pages branch. // HandlePages syncs, commits and pushes the changes from the pages directory to the pages branch.
func (p *Plugin) handlePages() error { func (p *Plugin) handlePages() ([]*types.Cmd, error) {
var cmds []*types.Cmd
defer os.RemoveAll(p.Settings.Repo.WorkDir) defer os.RemoveAll(p.Settings.Repo.WorkDir)
if err := p.handleClone(); err != nil { ccmd, err := p.handleClone()
return err if err != nil {
return cmds, err
} }
if err := execute( cmds = append(cmds, ccmd...)
rsyncDirectories(p.Settings.Pages, p.Settings.Repo), cmds = append(cmds,
); err != nil { SyncDirectories(
return err p.Settings.Pages.Exclude.Value(),
} p.Settings.Pages.Delete,
p.Settings.Pages.Directory,
p.Settings.Repo.WorkDir,
),
)
if err := p.handleCommit(); err != nil { cmds = append(cmds, p.handleCommit()...)
return err cmds = append(cmds, p.handlePush()...)
}
return p.handlePush() return cmds, nil
} }

View File

@ -1,11 +1,15 @@
package plugin package plugin
import ( import (
"fmt"
"github.com/thegeeklab/wp-git-action/git" "github.com/thegeeklab/wp-git-action/git"
wp "github.com/thegeeklab/wp-plugin-go/plugin" wp "github.com/thegeeklab/wp-plugin-go/v2/plugin"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
//go:generate go run ../internal/doc/main.go -output=../docs/data/data-raw.yaml
// Plugin implements provide the plugin. // Plugin implements provide the plugin.
type Plugin struct { type Plugin struct {
*wp.Plugin *wp.Plugin
@ -34,13 +38,181 @@ type Pages struct {
Delete bool Delete bool
} }
func New(options wp.Options, settings *Settings) *Plugin { type Action string
p := &Plugin{}
options.Execute = p.run func New(e wp.ExecuteFunc, build ...string) *Plugin {
p := &Plugin{
Settings: &Settings{},
}
options := wp.Options{
Name: "wp-git-action",
Description: "Perform git actions",
Flags: Flags(p.Settings, wp.FlagsPluginCategory),
Execute: p.run,
HideWoodpeckerFlags: true,
}
if len(build) > 0 {
options.Version = build[0]
}
if len(build) > 1 {
options.VersionMetadata = fmt.Sprintf("date=%s", build[1])
}
if e != nil {
options.Execute = e
}
p.Plugin = wp.New(options) p.Plugin = wp.New(options)
p.Settings = settings
return p return p
} }
// Flags returns a slice of CLI flags for the plugin.
func Flags(settings *Settings, category string) []cli.Flag {
return []cli.Flag{
&cli.StringSliceFlag{
Name: "action",
Usage: "git action to execute",
EnvVars: []string{"PLUGIN_ACTION"},
Destination: &settings.Action,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "author-name",
Usage: "git author name",
EnvVars: []string{"PLUGIN_AUTHOR_NAME", "CI_COMMIT_AUTHOR"},
Destination: &settings.Repo.Author.Name,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "author-email",
Usage: "git author email",
EnvVars: []string{"PLUGIN_AUTHOR_EMAIL", "CI_COMMIT_AUTHOR_EMAIL"},
Destination: &settings.Repo.Author.Email,
Required: true,
Category: category,
},
&cli.StringFlag{
Name: "netrc.machine",
Usage: "netrc remote machine name",
EnvVars: []string{"PLUGIN_NETRC_MACHINE", "CI_NETRC_MACHINE"},
Destination: &settings.Netrc.Machine,
Value: "github.com",
Category: category,
},
&cli.StringFlag{
Name: "netrc.username",
Usage: "netrc login user on the remote machine",
EnvVars: []string{"PLUGIN_NETRC_USERNAME", "CI_NETRC_USERNAME"},
Destination: &settings.Netrc.Login,
Value: "token",
Category: category,
},
&cli.StringFlag{
Name: "netrc.password",
Usage: "netrc login password on the remote machine",
EnvVars: []string{"PLUGIN_NETRC_PASSWORD", "CI_NETRC_PASSWORD"},
Destination: &settings.Netrc.Password,
Category: category,
},
&cli.StringFlag{
Name: "ssh-key",
Usage: "ssh private key for the remote repository",
EnvVars: []string{"PLUGIN_SSH_KEY"},
Destination: &settings.SSHKey,
Category: category,
},
&cli.StringFlag{
Name: "remote-url",
Usage: "url of the remote repository",
EnvVars: []string{"PLUGIN_REMOTE_URL", "CI_REPO_CLONE_URL"},
Destination: &settings.Repo.RemoteURL,
Category: category,
},
&cli.StringFlag{
Name: "branch",
Usage: "name of the git source branch",
EnvVars: []string{"PLUGIN_BRANCH"},
Destination: &settings.Repo.Branch,
Value: "main",
Category: category,
},
&cli.StringFlag{
Name: "path",
Usage: "path to clone git repository",
EnvVars: []string{"PLUGIN_PATH"},
Destination: &settings.Repo.WorkDir,
Category: category,
},
&cli.StringFlag{
Name: "commit-message",
Usage: "commit message",
EnvVars: []string{"PLUGIN_MESSAGE"},
Destination: &settings.Repo.CommitMsg,
Value: "[skip ci] commit dirty state",
Category: category,
},
&cli.BoolFlag{
Name: "force-push",
Usage: "enable force push to remote repository",
EnvVars: []string{"PLUGIN_FORCE"},
Destination: &settings.Repo.ForcePush,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "followtags",
Usage: "follow tags for pushes to remote repository",
EnvVars: []string{"PLUGIN_FOLLOWTAGS"},
Destination: &settings.Repo.PushFollowTags,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "empty-commit",
Usage: "allow empty commits",
EnvVars: []string{"PLUGIN_EMPTY_COMMIT"},
Destination: &settings.Repo.EmptyCommit,
Value: false,
Category: category,
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "bypass the pre-commit and commit-msg hooks",
EnvVars: []string{"PLUGIN_NO_VERIFY"},
Destination: &settings.Repo.NoVerify,
Value: false,
Category: category,
},
&cli.StringFlag{
Name: "pages.directory",
Usage: "source directory to be synchronized with the pages banch",
EnvVars: []string{"PLUGIN_PAGES_DIRECTORY"},
Destination: &settings.Pages.Directory,
Value: "docs/",
Category: category,
},
&cli.StringSliceFlag{
Name: "pages.exclude",
Usage: "files or directories to exclude from the pages rsync command",
EnvVars: []string{"PLUGIN_PAGES_EXCLUDE"},
Destination: &settings.Pages.Exclude,
Category: category,
},
&cli.BoolFlag{
Name: "pages.delete",
Usage: "add delete flag to pages rsync command",
EnvVars: []string{"PLUGIN_PAGES_DELETE"},
Destination: &settings.Pages.Delete,
Value: true,
Category: category,
},
}
}

43
plugin/rsync.go Normal file
View File

@ -0,0 +1,43 @@
package plugin
import (
"github.com/thegeeklab/wp-plugin-go/v2/types"
"golang.org/x/sys/execabs"
)
func SyncDirectories(exclude []string, del bool, src, dest string) *types.Cmd {
args := []string{
"-r",
"--exclude",
".git",
}
for _, item := range exclude {
args = append(
args,
"--exclude",
item,
)
}
if del {
args = append(
args,
"--delete",
)
}
args = append(
args,
".",
dest,
)
cmd := execabs.Command("rsync", args...)
cmd.Dir = src
return &types.Cmd{
Cmd: cmd,
}
}

46
plugin/rsync_test.go Normal file
View File

@ -0,0 +1,46 @@
package plugin
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSyncDirectories(t *testing.T) {
tests := []struct {
name string
exclude []string
del bool
src string
dest string
want []string
}{
{
name: "exclude .git and other patterns",
exclude: []string{"*.log", "temp/"},
del: false,
src: "/path/to/src",
dest: "/path/to/dest",
want: []string{
"rsync", "-r", "--exclude", ".git", "--exclude", "*.log",
"--exclude", "temp/", ".", "/path/to/dest",
},
},
{
name: "delete enabled",
exclude: []string{},
del: true,
src: "/path/to/src",
dest: "/path/to/dest",
want: []string{"rsync", "-r", "--exclude", ".git", "--delete", ".", "/path/to/dest"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := SyncDirectories(tt.exclude, tt.del, tt.src, tt.dest)
require.Equal(t, tt.want, cmd.Cmd.Args)
require.Equal(t, tt.src, cmd.Cmd.Dir)
})
}
}

15
plugin/util.go Normal file
View File

@ -0,0 +1,15 @@
package plugin
import (
"os/user"
)
func getUserHomeDir() string {
home := "/root"
if currentUser, err := user.Current(); err == nil {
home = currentUser.HomeDir
}
return home
}

View File

@ -1,58 +0,0 @@
package plugin
import (
"os"
"strings"
"github.com/rs/zerolog/log"
"github.com/thegeeklab/wp-git-action/git"
"golang.org/x/sys/execabs"
)
// helper function to simply wrap os execte command.
func execute(cmd *execabs.Cmd) error {
log.Debug().Msgf("+ %s", strings.Join(cmd.Args, " "))
cmd.Env = os.Environ()
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func rsyncDirectories(pages Pages, repo git.Repository) *execabs.Cmd {
args := []string{
"-r",
"--exclude",
".git",
}
for _, item := range pages.Exclude.Value() {
args = append(
args,
"--exclude",
item,
)
}
if pages.Delete {
args = append(
args,
"--delete",
)
}
args = append(
args,
".",
repo.WorkDir,
)
cmd := execabs.Command(
"rsync",
args...,
)
cmd.Dir = pages.Directory
return cmd
}