diff --git a/README.md b/README.md index 11163326..bcdf4aab 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@ You can also list projects (local repositories) (`gogh list`), find a project (` ## SYNOPSIS ``` -gogh get [--update,u] [--ssh] [--shallow] [( | / | )...] -gogh bulk-get [--update,u] [--ssh] [--shallow] -gogh pipe-get [--update,u] [--ssh] [--shallow] ... -gogh fork [--update,u] [--ssh] [--shallow] [--no-remote] [--remote-name=] [--org= | / | ) -gogh new [--update,u] [--ssh] [--shallow] [--no-remote] [--remote-name=] [--org= | / | ) -gogh list [--format,f=short|full|relative|url] [--primary,p] [] -gogh dump [--primary,p] [] -gogh find () -gogh where [--primary,p] [] -gogh repo [--user=] [--own] [--collaborate] [--member] [--visibility=] [--sort=] [--direction=] -gogh root [--all] +gogh [--config=] get [--update,u] [--ssh] [--shallow] [( | / | )...] +gogh [--config=] bulk-get [--update,u] [--ssh] [--shallow] +gogh [--config=] pipe-get [--update,u] [--ssh] [--shallow] ... +gogh [--config=] fork [--update,u] [--ssh] [--shallow] [--no-remote] [--remote-name=] [--org= | / | ) +gogh [--config=] new [--update,u] [--ssh] [--shallow] [--no-remote] [--remote-name=] [--org= | / | ) +gogh [--config=] list [--format,f=short|full|relative|url] [--primary,p] [] +gogh [--config=] dump [--primary,p] [] +gogh [--config=] find () +gogh [--config=] where [--primary,p] [] +gogh [--config=] repo [--user=] [--own] [--collaborate] [--member] [--visibility=] [--sort=] [--direction=] +gogh [--config=] root [--all] ``` ## INSTALLATION @@ -55,6 +55,56 @@ brew update brew install gogh ``` +## CONFIGURATIONS + +It's possible to change targets by a preference **YAML file**. +If you don't set `--config` flag or `GOGH_CONFIG` environment variable, +`gogh` loads configurations from `${XDG_CONFIG_HOME:-$HOME/.config}/gogh/config.yaml` + +Each of propoerties are able to be overwritten by environment variables. + +### (REQUIRED) `github.user` + +A name of your GitHub user (i.e. `kyoh86`). + +If an environment variable `GOGH_GITHUB_USER` is set, its value is used instead. + +### `root` + +The paths to directory under which cloned repositories are placed. +See [DIRECTORY STRUCTURES](#DIRECTORY+STRUCTURES) below. Default: `~/go/src`. + +This property can have multiple values. +If so, the first one becomes primary one i.e. new repository clones are always created under it. +You may want to specify `$GOPATH/src` as a secondary root. + +If an environment variable `GOGH_ROOT` is set, its value is used instead. + +### `log.level` + +The level to output logs (debug, info, warn, error or panic). Default: `warn`. + +If an environment variable `GOGH_LOG_LEVEL` is set, its value is used instead. + +### `github.token` + +The token to connect GitHub API. + +If an environment variable `GOGH_GITHUB_TOKEN` is set, its value is used instead. + +### `github.host` + +The host name to connect to GitHub. Default: `github.com`. + +If an environment variable `GOGH_GITHUB_HOST` is set, its value is used instead. + +### `log.(date|time|microseconds|longfile|shortfile|utc)` + +It can be customized that what log prints with message. Default: `log.time` is "yes", others are "no". +`"yes"` means that its optional property should be printed. + +If an environment variable `GOGH_LOG_(DATE|TIME|MICROSECONDS|LONGFILE|SHORTFILE|UTC)` is set, its value is used instead. + ## COMMANDS ``` @@ -130,46 +180,36 @@ Show a list of repositories for a user. ### `root` -Prints repositories' root (i.e. `gogh.root`). Without `--all` option, the primary one is shown. +Print repositories' root (i.e. `config get root`). Without `--all` option, the primary one is shown. -## ENVIRONMENT VARIABLES - -### GOGH_ROOT - -The paths to directory under which cloned repositories are placed. -See [DIRECTORY STRUCTURES](#DIRECTORY+STRUCTURES) below. Defaults to `~/go/src`. +### `config get-all` -This variable can have multiple values. -If so, the first one becomes primary one i.e. new repository clones are always created under it. -You may want to specify `$GOPATH/src` as a secondary root (environment variables should be expanded.) +Print all configuration options value. -### GOGH_GITHUB_USER +### `config get` -A name of your GitHub user (i.e. `kyoh86`). -If it is not set, gogh uses `GITHUB_USER` envar or OS user name from envar (`USERNAME` in windows, `USER` in others) instead. +Print one configuration option value. -### GOGH_LOG_LEVEL +### `config put` -The level to output logs (debug, info, warn, error or panic). Default: warn +Set or add one configuration option. -### GOGH_GHE_HOST +### `config unset` -Hostnames of your GitHub Enterprise installation. -This variable can have multiple values that separated with spaces. +Unset one configuration option. -### GOGH_GITHUB_TOKEN +## ENVIRONMENT VARIABLES -The token to connect GitHub API. -If it is not set, gogh uses `GITHUB_TOKEN` envar instead. +Some environment variables are used for flags. -### GOGH_GITHUB_HOST +### GOGH_CONFIG -The host to connect GitHub on default. -If it is not set, gogh uses `GITHUB_HOST` envar or `github.com` instead. +You can set it instead of `--config` flag (configuration file path). +Default: `${XDG_CONFIG_HOME:-$HOME/.config}/gogh/config.yaml`. ### GOGH_FLAG_ROOT_ALL -We can set it truely value and `gogh root` shows all of the roots like `gogh root --all`. +You can set it truely value and `gogh root` shows all of the roots like `gogh root --all`. If we want show only primary root, call `gogh root --no-all`. e.g. diff --git a/command/bulk.go b/command/bulk.go index 9df1f05c..fe0297ab 100644 --- a/command/bulk.go +++ b/command/bulk.go @@ -3,7 +3,6 @@ package command import ( "bufio" "io" - "os" "os/exec" "github.com/kyoh86/gogh/gogh" @@ -33,7 +32,7 @@ func Pipe(ctx gogh.Context, update, withSSH, shallow bool, command string, comma // Bulk get repositories specified in stdin. func Bulk(ctx gogh.Context, update, withSSH, shallow bool) error { - return bulkFromReader(ctx, os.Stdin, update, withSSH, shallow) + return bulkFromReader(ctx, ctx.Stdin(), update, withSSH, shallow) } // bulkFromReader bulk get repositories specified in reader. diff --git a/command/config.go b/command/config.go new file mode 100644 index 00000000..7f060426 --- /dev/null +++ b/command/config.go @@ -0,0 +1,42 @@ +package command + +import ( + "fmt" + + "github.com/kyoh86/gogh/config" +) + +func ConfigGetAll(cfg *config.Config) error { + for _, name := range config.OptionNames() { + opt, _ := config.Option(name) // ignore error: config.OptionNames covers all accessor + value := opt.Get(cfg) + fmt.Printf("%s = %s\n", name, value) + } + return nil +} + +func ConfigGet(cfg *config.Config, optionName string) error { + opt, err := config.Option(optionName) + if err != nil { + return err + } + value := opt.Get(cfg) + fmt.Println(value) + return nil +} + +func ConfigPut(cfg *config.Config, optionName, optionValue string) error { + opt, err := config.Option(optionName) + if err != nil { + return err + } + return opt.Put(cfg, optionValue) +} + +func ConfigUnset(cfg *config.Config, optionName string) error { + opt, err := config.Option(optionName) + if err != nil { + return err + } + return opt.Unset(cfg) +} diff --git a/command/config_get_all_test.go b/command/config_get_all_test.go new file mode 100644 index 00000000..d689380d --- /dev/null +++ b/command/config_get_all_test.go @@ -0,0 +1,40 @@ +package command_test + +import ( + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" +) + +func ExampleConfigGetAll() { + if err := command.ConfigGetAll(&config.Config{ + GitHub: config.GitHubConfig{ + Token: "tokenx1", + Host: "hostx1", + User: "kyoh86", + }, + Log: config.LogConfig{ + Level: "trace", + Date: config.TrueOption, + Time: config.FalseOption, + MicroSeconds: config.TrueOption, + LongFile: config.TrueOption, + ShortFile: config.TrueOption, + UTC: config.TrueOption, + }, + VRoot: []string{"/foo", "/bar"}, + }); err != nil { + panic(err) + } + // Unordered output: + // root = /foo:/bar + // github.host = hostx1 + // github.user = kyoh86 + // github.token = tokenx1 + // log.level = trace + // log.date = yes + // log.time = no + // log.microseconds = yes + // log.longfile = yes + // log.shortfile = yes + // log.utc = yes +} diff --git a/command/config_get_test.go b/command/config_get_test.go new file mode 100644 index 00000000..45d92366 --- /dev/null +++ b/command/config_get_test.go @@ -0,0 +1,23 @@ +package command_test + +import ( + "testing" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" + "github.com/stretchr/testify/assert" +) + +func ExampleConfigGet() { + if err := command.ConfigGet(&config.Config{ + VRoot: []string{"/foo", "/bar"}, + }, "root"); err != nil { + panic(err) + } + // Output: + // /foo:/bar +} + +func TestConfigGet(t *testing.T) { + assert.EqualError(t, command.ConfigGet(&config.Config{}, "invalid.name"), "invalid option name") +} diff --git a/command/config_put_test.go b/command/config_put_test.go new file mode 100644 index 00000000..3961dc92 --- /dev/null +++ b/command/config_put_test.go @@ -0,0 +1,15 @@ +package command_test + +import ( + "testing" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" + "github.com/stretchr/testify/assert" +) + +func TestConfigPut(t *testing.T) { + var cfg config.Config + assert.NoError(t, command.ConfigPut(&cfg, "github.host", "hostx1")) + assert.EqualError(t, command.ConfigPut(&cfg, "invalid.name", "hostx2"), "invalid option name") +} diff --git a/command/config_unset_test.go b/command/config_unset_test.go new file mode 100644 index 00000000..829bb224 --- /dev/null +++ b/command/config_unset_test.go @@ -0,0 +1,20 @@ +package command_test + +import ( + "testing" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" + "github.com/stretchr/testify/assert" +) + +func TestConfigUnset(t *testing.T) { + cfg := config.Config{ + GitHub: config.GitHubConfig{ + Host: "hostx1", + }, + } + assert.NoError(t, command.ConfigUnset(&cfg, "github.host")) + assert.Empty(t, cfg.GitHub.Host) + assert.EqualError(t, command.ConfigUnset(&cfg, "invalid.name"), "invalid option name") +} diff --git a/command/empty_test.go b/command/empty_test.go new file mode 100644 index 00000000..49df3399 --- /dev/null +++ b/command/empty_test.go @@ -0,0 +1,46 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/gogh/internal/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEmpty(t *testing.T) { + defaultGitClient = &mockGitClient{} + defaultHubClient = &mockHubClient{} + tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") + require.NoError(t, err) + defer os.RemoveAll(tmp) + ctx := &context.MockContext{ + MRoot: []string{tmp}, + MGitHubHost: "github.com", + } + + assert.NoError(t, Pipe(ctx, false, false, false, "echo", []string{"kyoh86/gogh"})) + ctx.MStdin = strings.NewReader(`kyoh86/gogh`) + assert.NoError(t, Bulk(ctx, false, false, false)) + mustRepo := func(name string) *gogh.Repo { + t.Helper() + repo, err := gogh.ParseRepo(name) + require.NoError(t, err) + return repo + } + assert.NoError(t, GetAll(ctx, false, false, false, gogh.Repos{ + *mustRepo("kyoh86/gogh"), + *mustRepo("kyoh86/vim-gogh"), + })) + assert.NoError(t, Get(ctx, false, false, false, mustRepo("kyoh86/gogh"))) + assert.NoError(t, Fork(ctx, false, false, false, false, "", "", mustRepo("kyoh86/gogh"))) + assert.NoError(t, List(ctx, gogh.ProjectListFormatShort, false, "")) + proj1 := filepath.Join(tmp, "github.com", "kyoh86", "gogh", ".git") + require.NoError(t, os.MkdirAll(proj1, 0755)) + assert.NoError(t, Root(ctx, false)) +} diff --git a/command/find.go b/command/find.go deleted file mode 100644 index 55d6dc4f..00000000 --- a/command/find.go +++ /dev/null @@ -1,22 +0,0 @@ -package command - -import ( - "fmt" - "log" - - "github.com/kyoh86/gogh/gogh" -) - -// Find a path of a project -func Find(ctx gogh.Context, repo *gogh.Repo) error { - log.Println("info: Finding a repository...") - path, err := gogh.FindProjectPath(ctx, repo) - if err != nil { - return err - } - if _, err := fmt.Fprintln(ctx.Stdout(), path); err != nil { - return err - } - - return nil -} diff --git a/command/fork.go b/command/fork.go index 81ab90a1..c720601c 100644 --- a/command/fork.go +++ b/command/fork.go @@ -15,12 +15,10 @@ func Fork(ctx gogh.Context, update, withSSH, shallow, noRemote bool, remoteName return err } log.Printf("info: Forking a repository") - if err := hubFork(ctx, project, repo, noRemote, remoteName, organization); err != nil { + if err := hub().Fork(ctx, project, repo, noRemote, remoteName, organization); err != nil { return err } - if _, err := fmt.Fprintln(ctx.Stdout(), project.RelPath); err != nil { - return err - } + fmt.Fprintln(ctx.Stdout(), project.RelPath) return nil } diff --git a/command/get.go b/command/get.go index 13965ad5..4acbdfc4 100644 --- a/command/get.go +++ b/command/get.go @@ -29,11 +29,11 @@ func Get(ctx gogh.Context, update, withSSH, shallow bool, repo *gogh.Repo) error } if !project.Exists { log.Println("info: Clone", fmt.Sprintf("%s -> %s", repoURL, project.FullPath)) - return gitClone(ctx, repoURL, project.FullPath, shallow) + return git().Clone(ctx, project, repoURL, shallow) } if update { log.Println("info: Update", project.FullPath) - return gitUpdate(ctx, project.FullPath) + return git().Update(ctx, project) } log.Println("warn: Exists", project.FullPath) return nil diff --git a/command/get_test.go b/command/get_test.go new file mode 100644 index 00000000..f5c8fe05 --- /dev/null +++ b/command/get_test.go @@ -0,0 +1,42 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/gogh/internal/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGet(t *testing.T) { + defaultGitClient = &mockGitClient{} + defaultHubClient = &mockHubClient{} + tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") + require.NoError(t, err) + defer os.RemoveAll(tmp) + ctx := &context.MockContext{ + MRoot: []string{tmp}, + MGitHubHost: "github.com", + } + mustRepo := func(name string) *gogh.Repo { + t.Helper() + repo, err := gogh.ParseRepo(name) + require.NoError(t, err) + return repo + } + assert.NoError(t, GetAll(ctx, false, false, false, gogh.Repos{ + *mustRepo("kyoh86/gogh"), + *mustRepo("kyoh86/vim-gogh"), + })) + assert.EqualError(t, GetAll(ctx, false, false, false, gogh.Repos{ + *mustRepo("https://example.com/kyoh86/gogh"), + }), `not supported host: "example.com"`) + assert.NoError(t, Get(ctx, false, false, false, mustRepo("kyoh86/gogh")), "success getting one") + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "github.com", "kyoh86", "gogh", ".git"), 0755)) + assert.NoError(t, Get(ctx, false, false, false, mustRepo("kyoh86/gogh")), "success getting one that is already exist") + assert.NoError(t, Get(ctx, true, false, false, mustRepo("kyoh86/gogh")), "success updating one that is already exist") +} diff --git a/command/git.go b/command/git.go index 4d09053d..cb9f5fa4 100644 --- a/command/git.go +++ b/command/git.go @@ -3,61 +3,35 @@ package command import ( "net/url" "os" - "os/exec" "path/filepath" + "github.com/kyoh86/gogh/command/internal" "github.com/kyoh86/gogh/gogh" ) -func gitInit( - ctx gogh.Context, - bare bool, - template string, - separateGitDir string, - shared gogh.ProjectShared, - directory string, -) error { - args := []string{"init"} - args = appendIf(args, "--bare", bare) - args = appendIfFilled(args, "--template", template) - args = appendIfFilled(args, "--separate-git-dir", separateGitDir) - args = appendIfFilled(args, "--shared", shared.String()) - args = append(args, directory) - cmd := exec.Command("git", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = ctx.Stdout() - cmd.Stderr = ctx.Stderr() - return execCommand(cmd) +type gitClient interface { + Init(ctx gogh.Context, project *gogh.Project, bare bool, template, separateGitDir string, shared gogh.ProjectShared) error + Clone(ctx gogh.Context, project *gogh.Project, remote *url.URL, shallow bool) error + Update(ctx gogh.Context, project *gogh.Project) error } -// gitClone git repository -func gitClone(ctx gogh.Context, remote *url.URL, local string, shallow bool) error { - dir, _ := filepath.Split(local) - err := os.MkdirAll(dir, 0755) - if err != nil { - return err - } - - args := []string{"clone"} - if shallow { - args = append(args, "--depth", "1") - } - args = append(args, remote.String(), local) - - cmd := exec.Command("git", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = ctx.Stdout() - cmd.Stderr = ctx.Stderr() - return execCommand(cmd) +type mockGitClient struct { } -// gitUpdate pulls changes from remote repository -func gitUpdate(ctx gogh.Context, local string) error { - cmd := exec.Command("git", "pull", "--ff-only") - cmd.Stdin = os.Stdin - cmd.Stdout = ctx.Stdout() - cmd.Stderr = ctx.Stderr() - cmd.Dir = local +func (i *mockGitClient) Init(ctx gogh.Context, project *gogh.Project, bare bool, template, separateGitDir string, shared gogh.ProjectShared) error { + return os.MkdirAll(filepath.Join(project.FullPath, ".git"), 0755) +} + +func (i *mockGitClient) Clone(ctx gogh.Context, project *gogh.Project, remote *url.URL, shallow bool) error { + return os.MkdirAll(filepath.Join(project.FullPath, ".git"), 0755) +} + +func (i *mockGitClient) Update(ctx gogh.Context, project *gogh.Project) error { + return nil +} + +var defaultGitClient gitClient = &internal.GitClient{} - return execCommand(cmd) +func git() gitClient { + return defaultGitClient } diff --git a/command/hub.go b/command/hub.go index 0dd7f6fe..3c24cc2c 100644 --- a/command/hub.go +++ b/command/hub.go @@ -1,105 +1,30 @@ package command import ( - "log" "net/url" - "os" - "strings" - "github.com/github/hub/commands" + "github.com/kyoh86/gogh/command/internal" "github.com/kyoh86/gogh/gogh" ) -func chdirTmp(dir string) (func() error, error) { - log.Printf("debug: Changing working directory to %s", dir) - cd, err := os.Getwd() - if err != nil { - return nil, err - } - if err := os.Chdir(dir); err != nil { - return nil, err - } - return func() error { - log.Printf("debug: Changing back working directory to %s", cd) - return os.Chdir(cd) - }, nil +type hubClient interface { + Fork(ctx gogh.Context, project *gogh.Project, repo *gogh.Repo, noRemote bool, remoteName, organization string) error + Create(ctx gogh.Context, project *gogh.Project, repo *gogh.Repo, description string, homepage *url.URL, private, browse, clipboard bool) error } -func hubFork( - ctx gogh.Context, - project *gogh.Project, - repo *gogh.Repo, - noRemote bool, - remoteName string, - organization string, -) (retErr error) { - tear, err := chdirTmp(project.FullPath) - if err != nil { - return err - } - defer func() { - if err := tear(); err != nil && retErr == nil { - retErr = err - } - }() +type mockHubClient struct { +} - hubArgs := []string{"hub", "fork"} - hubArgs = appendIf(hubArgs, "--no-remote", noRemote) - hubArgs = appendIfFilled(hubArgs, "--remote-name", remoteName) - hubArgs = appendIfFilled(hubArgs, "--organization", organization) - // call hub fork - if err := os.Setenv("GITHUB_HOST", repo.Host(ctx)); err != nil { - return err - } - if err := os.Setenv("GITHUB_TOKEN", ctx.GitHubToken()); err != nil { - return err - } - log.Printf("debug: Calling `%s`", strings.Join(hubArgs, " ")) - if err := commands.CmdRunner.Execute(hubArgs); err != nil { - return err - } +func (i *mockHubClient) Fork(gogh.Context, *gogh.Project, *gogh.Repo, bool, string, string) error { + return nil +} +func (i *mockHubClient) Create(gogh.Context, *gogh.Project, *gogh.Repo, string, *url.URL, bool, bool, bool) error { return nil } -func hubCreate( - ctx gogh.Context, - private bool, - description string, - homepage *url.URL, - browse bool, - clipboard bool, - repo *gogh.Repo, - directory string, -) (retErr error) { - tear, err := chdirTmp(directory) - if err != nil { - return err - } - defer func() { - if err := tear(); err != nil && retErr == nil { - retErr = err - } - }() +var defaultHubClient hubClient = &internal.HubClient{} - hubArgs := []string{"hub", "create"} - hubArgs = appendIf(hubArgs, "-p", private) - hubArgs = appendIf(hubArgs, "-o", browse) - hubArgs = appendIf(hubArgs, "-c", clipboard) - hubArgs = appendIfFilled(hubArgs, "-d", description) - if homepage != nil { - hubArgs = append(hubArgs, "-h", homepage.String()) - } - hubArgs = append(hubArgs, repo.FullName(ctx)) - log.Printf("debug: Calling `%s`", strings.Join(hubArgs, " ")) - if err := os.Setenv("GITHUB_HOST", repo.Host(ctx)); err != nil { - return err - } - if err := os.Setenv("GITHUB_TOKEN", ctx.GitHubToken()); err != nil { - return err - } - if err := commands.CmdRunner.Execute(hubArgs); err != nil { - return err - } - return nil +func hub() hubClient { + return defaultHubClient } diff --git a/command/internal/git.go b/command/internal/git.go new file mode 100644 index 00000000..7a009d27 --- /dev/null +++ b/command/internal/git.go @@ -0,0 +1,63 @@ +package internal + +import ( + "net/url" + "os" + "os/exec" + "path/filepath" + + "github.com/kyoh86/gogh/gogh" +) + +type GitClient struct{} + +func (c *GitClient) Init( + ctx gogh.Context, + project *gogh.Project, + bare bool, + template string, + separateGitDir string, + shared gogh.ProjectShared, +) error { + args := []string{"init"} + args = appendIf(args, "--bare", bare) + args = appendIfFilled(args, "--template", template) + args = appendIfFilled(args, "--separate-git-dir", separateGitDir) + args = appendIfFilled(args, "--shared", shared.String()) + args = append(args, project.FullPath) + cmd := exec.Command("git", args...) + cmd.Stdin = ctx.Stdin() + cmd.Stdout = ctx.Stdout() + cmd.Stderr = ctx.Stderr() + return execCommand(cmd) +} + +func (c *GitClient) Clone(ctx gogh.Context, project *gogh.Project, remote *url.URL, shallow bool) error { + dir, _ := filepath.Split(project.FullPath) + err := os.MkdirAll(dir, 0755) + if err != nil { + return err + } + + args := []string{"clone"} + if shallow { + args = append(args, "--depth", "1") + } + args = append(args, remote.String(), project.FullPath) + + cmd := exec.Command("git", args...) + cmd.Stdin = ctx.Stdin() + cmd.Stdout = ctx.Stdout() + cmd.Stderr = ctx.Stderr() + return execCommand(cmd) +} + +func (c *GitClient) Update(ctx gogh.Context, project *gogh.Project) error { + cmd := exec.Command("git", "pull", "--ff-only") + cmd.Stdin = ctx.Stdin() + cmd.Stdout = ctx.Stdout() + cmd.Stderr = ctx.Stderr() + cmd.Dir = project.FullPath + + return execCommand(cmd) +} diff --git a/command/internal/hub.go b/command/internal/hub.go new file mode 100644 index 00000000..1c9bc2cc --- /dev/null +++ b/command/internal/hub.go @@ -0,0 +1,107 @@ +package internal + +import ( + "log" + "net/url" + "os" + "strings" + + "github.com/github/hub/commands" + "github.com/kyoh86/gogh/gogh" +) + +func chdirTmp(dir string) (func() error, error) { + log.Printf("debug: Changing working directory to %s", dir) + cd, err := os.Getwd() + if err != nil { + return nil, err + } + if err := os.Chdir(dir); err != nil { + return nil, err + } + return func() error { + log.Printf("debug: Changing back working directory to %s", cd) + return os.Chdir(cd) + }, nil +} + +type HubClient struct{} + +func (c *HubClient) Fork( + ctx gogh.Context, + project *gogh.Project, + repo *gogh.Repo, + noRemote bool, + remoteName string, + organization string, +) (retErr error) { + tear, err := chdirTmp(project.FullPath) + if err != nil { + return err + } + defer func() { + if err := tear(); err != nil && retErr == nil { + retErr = err + } + }() + + hubArgs := []string{"hub", "fork"} + hubArgs = appendIf(hubArgs, "--no-remote", noRemote) + hubArgs = appendIfFilled(hubArgs, "--remote-name", remoteName) + hubArgs = appendIfFilled(hubArgs, "--organization", organization) + // call hub fork + if err := os.Setenv("GITHUB_HOST", repo.Host(ctx)); err != nil { + return err + } + if err := os.Setenv("GITHUB_TOKEN", ctx.GitHubToken()); err != nil { + return err + } + log.Printf("debug: Calling `%s`", strings.Join(hubArgs, " ")) + if err := commands.CmdRunner.Execute(hubArgs); err != nil { + return err + } + + return nil +} + +func (c *HubClient) Create( + ctx gogh.Context, + project *gogh.Project, + repo *gogh.Repo, + description string, + homepage *url.URL, + private, + browse, + clipboard bool, +) (retErr error) { + tear, err := chdirTmp(project.FullPath) + if err != nil { + return err + } + defer func() { + if err := tear(); err != nil && retErr == nil { + retErr = err + } + }() + + hubArgs := []string{"hub", "create"} + hubArgs = appendIf(hubArgs, "-p", private) + hubArgs = appendIf(hubArgs, "-o", browse) + hubArgs = appendIf(hubArgs, "-c", clipboard) + hubArgs = appendIfFilled(hubArgs, "-d", description) + if homepage != nil { + hubArgs = append(hubArgs, "-h", homepage.String()) + } + hubArgs = append(hubArgs, repo.FullName(ctx)) + log.Printf("debug: Calling `%s`", strings.Join(hubArgs, " ")) + if err := os.Setenv("GITHUB_HOST", repo.Host(ctx)); err != nil { + return err + } + if err := os.Setenv("GITHUB_TOKEN", ctx.GitHubToken()); err != nil { + return err + } + if err := commands.CmdRunner.Execute(hubArgs); err != nil { + return err + } + return nil +} diff --git a/command/param.go b/command/internal/param.go similarity index 94% rename from command/param.go rename to command/internal/param.go index 9a33b355..a7793c04 100644 --- a/command/param.go +++ b/command/internal/param.go @@ -1,4 +1,4 @@ -package command +package internal func appendIf(array []string, flag string, value bool) []string { if value { diff --git a/command/run.go b/command/internal/run.go similarity index 97% rename from command/run.go rename to command/internal/run.go index 94eb466c..a1307eb5 100644 --- a/command/run.go +++ b/command/internal/run.go @@ -1,4 +1,4 @@ -package command +package internal import ( "fmt" diff --git a/command/list_test.go b/command/list_test.go new file mode 100644 index 00000000..e6888e83 --- /dev/null +++ b/command/list_test.go @@ -0,0 +1,62 @@ +package command_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" + "github.com/kyoh86/gogh/gogh" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ExampleList() { + tmp, _ := ioutil.TempDir(os.TempDir(), "gogh-test") + defer os.RemoveAll(tmp) + _ = os.MkdirAll(filepath.Join(tmp, "example.com", "kyoh86", "gogh", ".git"), 0755) + _ = os.MkdirAll(filepath.Join(tmp, "example.com", "owner", "name", ".git"), 0755) + _ = os.MkdirAll(filepath.Join(tmp, "example.com", "owner", "empty"), 0755) + + if err := command.List(&config.Config{ + VRoot: []string{tmp}, + GitHub: config.GitHubConfig{Host: "example.com"}, + }, + gogh.ProjectListFormatURL, + true, + "", + ); err != nil { + panic(err) + } + // Unordered output: + // https://example.com/kyoh86/gogh + // https://example.com/owner/name +} + +func TestList(t *testing.T) { + tmp, _ := ioutil.TempDir(os.TempDir(), "gogh-test") + defer os.RemoveAll(tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "example.com", "kyoh86", "gogh", ".git"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "example.com", "owner", "name", ".git"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "example.com", "owner", "empty"), 0755)) + + assert.Error(t, command.List(&config.Config{ + VRoot: []string{tmp}, + GitHub: config.GitHubConfig{Host: "example.com"}, + }, + gogh.ProjectListFormat("invalid format"), + false, + "", + ), "invalid format") + + assert.Error(t, command.List(&config.Config{ + VRoot: []string{tmp, "/\x00"}, + GitHub: config.GitHubConfig{Host: "example.com"}, + }, + gogh.ProjectListFormatURL, + false, + "", + ), "invalid root") +} diff --git a/command/new.go b/command/new.go index c2969ae8..9589256d 100644 --- a/command/new.go +++ b/command/new.go @@ -39,11 +39,11 @@ func New( // git init log.Println("info: Initializing a repository") - if err := gitInit(ctx, bare, template, separateGitDir, shared, project.FullPath); err != nil { + if err := git().Init(ctx, project, bare, template, separateGitDir, shared); err != nil { return err } // hub create log.Println("info: Creating a new repository in GitHub") - return hubCreate(ctx, private, description, homepage, browse, clipboard, repo, project.FullPath) + return hub().Create(ctx, project, repo, description, homepage, private, browse, clipboard) } diff --git a/command/new_test.go b/command/new_test.go new file mode 100644 index 00000000..0138117e --- /dev/null +++ b/command/new_test.go @@ -0,0 +1,60 @@ +package command + +import ( + "io/ioutil" + "net/url" + "os" + "testing" + + "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/gogh/internal/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + defaultGitClient = &mockGitClient{} + defaultHubClient = &mockHubClient{} + root, err := ioutil.TempDir(os.TempDir(), "gogh-test") + require.NoError(t, err) + defer os.RemoveAll(root) + + ctx := &context.MockContext{ + MRoot: []string{root}, + MGitHubHost: "github.com", + } + + mustRepo := func(name string) *gogh.Repo { + t.Helper() + repo, err := gogh.ParseRepo(name) + require.NoError(t, err) + return repo + } + assert.NoError(t, New( + ctx, + false, + "", + &url.URL{}, + false, + false, + false, + "", + "", + gogh.ProjectShared("false"), + mustRepo("kyoh86/gogh"), + )) + + assert.EqualError(t, New( + ctx, + false, + "", + &url.URL{}, + false, + false, + false, + "", + "", + gogh.ProjectShared("false"), + mustRepo("kyoh86/gogh"), + ), "project already exists") +} diff --git a/command/remote/doc.go b/command/remote/doc.go deleted file mode 100644 index 6bb1410f..00000000 --- a/command/remote/doc.go +++ /dev/null @@ -1,27 +0,0 @@ -/* -Package remote contains commands those get informations from GitHub. - -* Commands will be organized like below that follows GitHub API v3 specification. - See: https://developer.github.com/v3/ - -| Function | Subcommand | -|-------------------------|-------------------------------| -| List repos | repo | -| Search repos | search repo | -| Search commits | search commit | -| Search code | search code | -| Search pull requests | search issue | -| Search issues | search pr, pull, pull-request | -| Search users | search user | -| Search topics | search topic | -| Search labels | search label | - -All of plural form of each subcommand can be used. (i.e. "repos") - -* This should not manage pull-request and issues. -We should request features and enhancements to `hub` (https://github.com/github/hub). - -* This should not manage gists. - We should request features and enhancements to `gist` (http://defunkt.io/gist/). -*/ -package remote diff --git a/command/remote/repo.go b/command/remote/repo.go deleted file mode 100644 index 9e90a294..00000000 --- a/command/remote/repo.go +++ /dev/null @@ -1,20 +0,0 @@ -package remote - -import ( - "fmt" - - "github.com/kyoh86/gogh/gogh" - remo "github.com/kyoh86/gogh/gogh/remote" -) - -// Repo will show a list of repositories for a user. -func Repo(ctx gogh.Context, user string, own, collaborate, member bool, visibility, sort, direction string) error { - repos, err := remo.Repo(ctx, user, own, collaborate, member, visibility, sort, direction) - if err != nil { - return err - } - for _, repo := range repos { - fmt.Println(repo) - } - return nil -} diff --git a/command/repos.go b/command/repos.go new file mode 100644 index 00000000..4c706818 --- /dev/null +++ b/command/repos.go @@ -0,0 +1,20 @@ +package command + +import ( + "fmt" + + "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/gogh/remote" +) + +// Repos will show a list of repositories for a user. +func Repos(ctx gogh.Context, user string, own, collaborate, member bool, visibility, sort, direction string) error { + repos, err := remote.Repos(ctx, user, own, collaborate, member, visibility, sort, direction) + if err != nil { + return err + } + for _, repo := range repos { + fmt.Println(repo) + } + return nil +} diff --git a/command/root.go b/command/root.go index 7ea6f31e..5f67a717 100644 --- a/command/root.go +++ b/command/root.go @@ -10,14 +10,12 @@ import ( // Root prints a gogh.root func Root(ctx gogh.Context, all bool) error { if !all { - _, err := fmt.Fprintln(ctx.Stdout(), ctx.PrimaryRoot()) - return err + fmt.Fprintln(ctx.Stdout(), ctx.PrimaryRoot()) + return nil } log.Println("info: Finding all roots...") - for _, root := range ctx.Roots() { - if _, err := fmt.Fprintln(ctx.Stdout(), root); err != nil { - return err - } + for _, root := range ctx.Root() { + fmt.Fprintln(ctx.Stdout(), root) } return nil } diff --git a/command/root_test.go b/command/root_test.go new file mode 100644 index 00000000..3af67da9 --- /dev/null +++ b/command/root_test.go @@ -0,0 +1,27 @@ +package command_test + +import ( + "fmt" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" +) + +func ExampleRoot() { + ctx := &config.Config{ + VRoot: config.PathListOption{"/foo", "/bar"}, + } + + if err := command.Root(ctx, false); err != nil { + panic(err) + } + fmt.Println() + if err := command.Root(ctx, true); err != nil { + panic(err) + } + // Output: + // /foo + // + // /foo + // /bar +} diff --git a/command/setup.go b/command/setup.go index f050f355..d423debb 100644 --- a/command/setup.go +++ b/command/setup.go @@ -13,20 +13,12 @@ func Setup(ctx gogh.Context, cdFuncName, shell string) error { _, shName := filepath.Split(shell) switch shName { case "zsh": - if _, err := fmt.Fprintf(ctx.Stdout(), `function %s { cd $(gogh find $@) }%s`, cdFuncName, "\n"); err != nil { - return err - } - if _, err := fmt.Fprintf(ctx.Stdout(), `eval "$(gogh --completion-script-zsh)"%s`, "\n"); err != nil { - return err - } + fmt.Fprintf(ctx.Stdout(), `function %s { cd $(gogh find $@) }%s`, cdFuncName, "\n") + fmt.Fprintf(ctx.Stdout(), `eval "$(gogh --completion-script-zsh)"%s`, "\n") return nil case "bash": - if _, err := fmt.Fprintf(ctx.Stdout(), `function %s { cd $(gogh find $@) }%s`, cdFuncName, "\n"); err != nil { - return err - } - if _, err := fmt.Fprintf(ctx.Stdout(), `eval "$(gogh --completion-script-bash)"%s`, "\n"); err != nil { - return err - } + fmt.Fprintf(ctx.Stdout(), `function %s { cd $(gogh find $@) }%s`, cdFuncName, "\n") + fmt.Fprintf(ctx.Stdout(), `eval "$(gogh --completion-script-bash)"%s`, "\n") return nil default: return fmt.Errorf("unsupported shell %q", shell) diff --git a/command/setup_test.go b/command/setup_test.go new file mode 100644 index 00000000..b24d4dfb --- /dev/null +++ b/command/setup_test.go @@ -0,0 +1,27 @@ +package command_test + +import ( + "testing" + + "github.com/kyoh86/gogh/command" + "github.com/kyoh86/gogh/config" + "github.com/stretchr/testify/assert" +) + +func ExampleSetup() { + if err := command.Setup(&config.Config{}, "gogh-cd", "zsh"); err != nil { + panic(err) + } + if err := command.Setup(&config.Config{}, "gogh-cd", "bash"); err != nil { + panic(err) + } + // Output: + // function gogh-cd { cd $(gogh find $@) } + // eval "$(gogh --completion-script-zsh)" + // function gogh-cd { cd $(gogh find $@) } + // eval "$(gogh --completion-script-bash)" +} + +func TestSetup(t *testing.T) { + assert.EqualError(t, command.Setup(&config.Config{}, "gogh-cd", "invalid"), "unsupported shell \"invalid\"") +} diff --git a/command/where.go b/command/where.go index da32f154..5cd925c8 100644 --- a/command/where.go +++ b/command/where.go @@ -8,35 +8,51 @@ import ( ) // Where is a local project -func Where(ctx gogh.Context, primary bool, query string) error { +func Where(ctx gogh.Context, primary bool, exact bool, query string) error { log.Printf("info: Finding a repository by query %s", query) - var walk gogh.Walker = gogh.Walk + walk := gogh.Walk + finder := gogh.FindProject if primary { walk = gogh.WalkInPrimary + finder = gogh.FindProjectInPrimary } formatter := gogh.FullPathFormatter() - count := 0 - if err := gogh.Query(ctx, query, walk, func(p *gogh.Project) error { - formatter.Add(p) - count++ - return nil - }); err != nil { - return err + if exact { + repo, err := gogh.ParseRepo(query) + if err != nil { + return err + } + project, err := finder(ctx, repo) + if err != nil { + return err + } + formatter.Add(project) + } else { + if err := gogh.Query(ctx, query, walk, func(p *gogh.Project) error { + formatter.Add(p) + return nil + }); err != nil { + return err + } } - if count > 1 { + switch l := formatter.Len(); { + case l == 1: + if err := formatter.PrintAll(ctx.Stdout(), "\n"); err != nil { + return err + } + case l < 1: + log.Println("error: No repository is found") + return gogh.ProjectNotFound + default: log.Println("error: Multiple repositories are found") if err := formatter.PrintAll(ctx.Stderr(), "\n"); err != nil { return err } return errors.New("try more precise name") - } else { - if err := formatter.PrintAll(ctx.Stdout(), "\n"); err != nil { - return err - } } return nil } diff --git a/command/where_test.go b/command/where_test.go new file mode 100644 index 00000000..45121019 --- /dev/null +++ b/command/where_test.go @@ -0,0 +1,90 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/kyoh86/gogh/internal/context" + "github.com/kyoh86/gogh/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWhere(t *testing.T) { + root1, err := ioutil.TempDir(os.TempDir(), "gogh-test1") + require.NoError(t, err) + defer os.RemoveAll(root1) + root2, err := ioutil.TempDir(os.TempDir(), "gogh-test2") + require.NoError(t, err) + defer os.RemoveAll(root2) + + proj1 := filepath.Join(root1, "github.com", "kyoh86", "vim-gogh", ".git") + require.NoError(t, os.MkdirAll(proj1, 0755)) + proj2 := filepath.Join(root2, "github.com", "kyoh86", "gogh", ".git") + require.NoError(t, os.MkdirAll(proj2, 0755)) + + assert.EqualError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, false, "gogh"), "try more precise name") + + assert.EqualError(t, Where(&context.MockContext{ + MStderr: testutil.DefaultErrorWriter, + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, false, "gogh"), "error writer") + + assert.EqualError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, true, true, "gogh"), "project not found") + + assert.EqualError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, false, "noone"), "project not found") + + assert.NoError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, true, false, "gogh")) + + assert.NoError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, true, "gogh")) + + assert.NoError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, true, true, "vim-gogh")) + + assert.EqualError(t, Where(&context.MockContext{ + MStdout: testutil.DefaultErrorWriter, + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, true, true, "vim-gogh"), "error writer") + + assert.EqualError(t, Where(&context.MockContext{ + MRoot: []string{root1, root2}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, true, ".."), "'.' or '..' is reserved name") + + assert.EqualError(t, Where(&context.MockContext{ + MRoot: []string{"/\x00"}, + MGitHubHost: "github.com", + MGitHubUser: "kyoh86", + }, false, false, "gogh"), "stat /\x00: invalid argument") + +} diff --git a/config/accessor.go b/config/accessor.go new file mode 100644 index 00000000..87a18e08 --- /dev/null +++ b/config/accessor.go @@ -0,0 +1,271 @@ +package config + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/kyoh86/gogh/gogh" + "github.com/thoas/go-funk" +) + +var ( + EmptyValue = errors.New("empty value") + RemoveFromMonoOption = errors.New("removing from mono option") + InvalidOptionName = errors.New("invalid option name") + TokenMustNotSave = errors.New("token must not save") +) + +type OptionAccessor struct { + optionName string + getter func(cfg *Config) string + putter func(cfg *Config, value string) error + unsetter func(cfg *Config) error +} + +func (a OptionAccessor) Get(cfg *Config) string { return a.getter(cfg) } +func (a OptionAccessor) Put(cfg *Config, value string) error { return a.putter(cfg, value) } +func (a OptionAccessor) Unset(cfg *Config) error { return a.unsetter(cfg) } + +var ( + configAccessor map[string]OptionAccessor + optionNames []string + optionAccessors = []OptionAccessor{ + rootOptionAccessor, + gitHubHostOptionAccessor, + gitHubUserOptionAccessor, + gitHubTokenOptionAccessor, + logLevelOptionAccessor, + logDateOptionAccessor, + logTimeOptionAccessor, + logMicroSecondsOptionAccessor, + logLongFileOptionAccessor, + logShortFileOptionAccessor, + logUTCOptionAccessor, + } +) + +func init() { + m := map[string]OptionAccessor{} + n := make([]string, 0, len(optionAccessors)) + for _, a := range optionAccessors { + n = append(n, a.optionName) + m[a.optionName] = a + } + configAccessor = m + optionNames = n +} + +func Option(optionName string) (*OptionAccessor, error) { + a, ok := configAccessor[optionName] + if !ok { + return nil, InvalidOptionName + } + return &a, nil +} + +func OptionNames() []string { + return optionNames +} + +var ( + gitHubUserOptionAccessor = OptionAccessor{ + optionName: "github.user", + getter: func(cfg *Config) string { + return cfg.GitHubUser() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + if err := gogh.ValidateOwner(value); err != nil { + return err + } + cfg.GitHub.User = value + return nil + }, + unsetter: func(cfg *Config) error { + cfg.GitHub.User = "" + return nil + }, + } + + gitHubTokenOptionAccessor = OptionAccessor{ + optionName: "github.token", + getter: func(cfg *Config) string { + return cfg.GitHubToken() + }, + putter: func(cfg *Config, value string) error { + return TokenMustNotSave + }, + unsetter: func(cfg *Config) error { + cfg.GitHub.Token = "" + return nil + }, + } + + gitHubHostOptionAccessor = OptionAccessor{ + optionName: "github.host", + getter: func(cfg *Config) string { + return cfg.GitHubHost() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + cfg.GitHub.Host = value + return nil + }, + unsetter: func(cfg *Config) error { + cfg.GitHub.Host = "" + return nil + }, + } + + logLevelOptionAccessor = OptionAccessor{ + optionName: "log.level", + getter: func(cfg *Config) string { + return cfg.LogLevel() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + if err := gogh.ValidateLogLevel(value); err != nil { + return err + } + cfg.Log.Level = value + return nil + }, + unsetter: func(cfg *Config) error { + cfg.Log.Level = "" + return nil + }, + } + + logDateOptionAccessor = OptionAccessor{ + optionName: "log.date", + getter: func(cfg *Config) string { + return cfg.Log.Date.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.Date.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.Date = EmptyBoolOption + return nil + }, + } + + logTimeOptionAccessor = OptionAccessor{ + optionName: "log.time", + getter: func(cfg *Config) string { + return cfg.Log.Time.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.Time.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.Time = EmptyBoolOption + return nil + }, + } + + logMicroSecondsOptionAccessor = OptionAccessor{ + optionName: "log.microseconds", + getter: func(cfg *Config) string { + return cfg.Log.MicroSeconds.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.MicroSeconds.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.MicroSeconds = EmptyBoolOption + return nil + }, + } + + logLongFileOptionAccessor = OptionAccessor{ + optionName: "log.longfile", + getter: func(cfg *Config) string { + return cfg.Log.LongFile.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.LongFile.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.LongFile = EmptyBoolOption + return nil + }, + } + + logShortFileOptionAccessor = OptionAccessor{ + optionName: "log.shortfile", + getter: func(cfg *Config) string { + return cfg.Log.ShortFile.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.ShortFile.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.ShortFile = EmptyBoolOption + return nil + }, + } + + logUTCOptionAccessor = OptionAccessor{ + optionName: "log.utc", + getter: func(cfg *Config) string { + return cfg.Log.UTC.String() + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + return cfg.Log.UTC.Decode(value) + }, + unsetter: func(cfg *Config) error { + cfg.Log.UTC = EmptyBoolOption + return nil + }, + } + + rootOptionAccessor = OptionAccessor{ + optionName: "root", + getter: func(cfg *Config) string { + return strings.Join(cfg.Root(), string(filepath.ListSeparator)) + }, + putter: func(cfg *Config, value string) error { + if value == "" { + return EmptyValue + } + + list := filepath.SplitList(value) + + if err := gogh.ValidateRoots(list); err != nil { + return err + } + cfg.VRoot = funk.UniqString(append(cfg.VRoot, list...)) + return nil + }, + unsetter: func(cfg *Config) error { + cfg.VRoot = nil + return nil + }, + } +) diff --git a/config/accessor_test.go b/config/accessor_test.go new file mode 100644 index 00000000..bd23b8bb --- /dev/null +++ b/config/accessor_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccessor(t *testing.T) { + t.Run("getting", func(t *testing.T) { + mustOption := func(acc *OptionAccessor, err error) *OptionAccessor { + t.Helper() + require.NoError(t, err) + return acc + } + var cfg Config + cfg.GitHub.Token = "token1" + cfg.GitHub.Host = "hostx1" + cfg.GitHub.User = "kyoh86" + cfg.Log.Level = "trace" + cfg.Log.Date = TrueOption + cfg.Log.Time = FalseOption + cfg.Log.MicroSeconds = TrueOption + cfg.Log.LongFile = TrueOption + cfg.Log.ShortFile = TrueOption + cfg.Log.UTC = TrueOption + cfg.VRoot = []string{"/foo", "/bar"} + + _, err := Option("invalid name") + assert.EqualError(t, err, "invalid option name") + assert.Equal(t, "token1", mustOption(Option("github.token")).Get(&cfg)) + assert.Equal(t, "hostx1", mustOption(Option("github.host")).Get(&cfg)) + assert.Equal(t, "kyoh86", mustOption(Option("github.user")).Get(&cfg)) + assert.Equal(t, "trace", mustOption(Option("log.level")).Get(&cfg)) + assert.Equal(t, "yes", mustOption(Option("log.date")).Get(&cfg)) + assert.Equal(t, "no", mustOption(Option("log.time")).Get(&cfg)) + assert.Equal(t, "yes", mustOption(Option("log.microseconds")).Get(&cfg)) + assert.Equal(t, "yes", mustOption(Option("log.longfile")).Get(&cfg)) + assert.Equal(t, "yes", mustOption(Option("log.shortfile")).Get(&cfg)) + assert.Equal(t, "yes", mustOption(Option("log.utc")).Get(&cfg)) + assert.Equal(t, "/foo:/bar", mustOption(Option("root")).Get(&cfg)) + }) + t.Run("putting", func(t *testing.T) { + mustOption := func(acc *OptionAccessor, err error) *OptionAccessor { + t.Helper() + require.NoError(t, err) + return acc + } + var cfg Config + assert.NoError(t, mustOption(Option("github.host")).Put(&cfg, "hostx1")) + assert.NoError(t, mustOption(Option("github.user")).Put(&cfg, "kyoh86")) + assert.NoError(t, mustOption(Option("log.level")).Put(&cfg, "trace")) + assert.NoError(t, mustOption(Option("log.date")).Put(&cfg, "yes")) + assert.NoError(t, mustOption(Option("log.time")).Put(&cfg, "no")) + assert.NoError(t, mustOption(Option("log.microseconds")).Put(&cfg, "yes")) + assert.NoError(t, mustOption(Option("log.longfile")).Put(&cfg, "yes")) + assert.NoError(t, mustOption(Option("log.shortfile")).Put(&cfg, "yes")) + assert.NoError(t, mustOption(Option("log.utc")).Put(&cfg, "yes")) + assert.NoError(t, mustOption(Option("root")).Put(&cfg, "/foo:/bar")) + + assert.Equal(t, "", cfg.GitHub.Token) + assert.Equal(t, "hostx1", cfg.GitHub.Host) + assert.Equal(t, "kyoh86", cfg.GitHub.User) + assert.Equal(t, "trace", cfg.Log.Level) + assert.Equal(t, TrueOption, cfg.Log.Date) + assert.True(t, cfg.LogDate()) + assert.Equal(t, FalseOption, cfg.Log.Time) + assert.False(t, cfg.LogTime()) + assert.Equal(t, TrueOption, cfg.Log.MicroSeconds) + assert.True(t, cfg.LogMicroSeconds()) + assert.Equal(t, TrueOption, cfg.Log.LongFile) + assert.True(t, cfg.LogLongFile()) + assert.Equal(t, TrueOption, cfg.Log.ShortFile) + assert.True(t, cfg.LogShortFile()) + assert.Equal(t, TrueOption, cfg.Log.UTC) + assert.True(t, cfg.LogUTC()) + assert.Equal(t, PathListOption{"/foo", "/bar"}, cfg.VRoot) + }) + t.Run("putting error", func(t *testing.T) { + mustOption := func(acc *OptionAccessor, err error) *OptionAccessor { + t.Helper() + require.NoError(t, err) + return acc + } + var cfg Config + assert.EqualError(t, mustOption(Option("github.token")).Put(&cfg, "token1"), "token must not save") + + assert.EqualError(t, mustOption(Option("github.host")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("github.user")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.level")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.date")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.time")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.microseconds")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.longfile")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.shortfile")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("log.utc")).Put(&cfg, ""), "empty value") + assert.EqualError(t, mustOption(Option("root")).Put(&cfg, ""), "empty value") + + assert.Error(t, mustOption(Option("github.user")).Put(&cfg, "-kyoh86"), "invalid github username") + assert.Error(t, mustOption(Option("log.level")).Put(&cfg, "foobar"), "invalid log level") + assert.Error(t, mustOption(Option("log.date")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("log.time")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("log.microseconds")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("log.longfile")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("log.shortfile")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("log.utc")).Put(&cfg, "invalid value"), "invalid value") + assert.Error(t, mustOption(Option("root")).Put(&cfg, "\x00"), "invalid value") + + assert.Equal(t, "", cfg.GitHub.Token) + assert.Equal(t, "", cfg.GitHub.Host) + assert.Equal(t, "", cfg.GitHub.User) + assert.Equal(t, "", cfg.Log.Level) + assert.Equal(t, EmptyBoolOption, cfg.Log.Date) + assert.Equal(t, EmptyBoolOption, cfg.Log.Time) + assert.Equal(t, EmptyBoolOption, cfg.Log.MicroSeconds) + assert.Equal(t, EmptyBoolOption, cfg.Log.LongFile) + assert.Equal(t, EmptyBoolOption, cfg.Log.ShortFile) + assert.Equal(t, EmptyBoolOption, cfg.Log.UTC) + assert.Empty(t, cfg.VRoot) + + }) + t.Run("unsetting", func(t *testing.T) { + var cfg Config + cfg.GitHub.Token = "token1" + cfg.GitHub.Host = "hostx1" + cfg.GitHub.User = "kyoh86" + cfg.Log.Level = "trace" + cfg.Log.Date = TrueOption + cfg.Log.Time = FalseOption + cfg.Log.MicroSeconds = TrueOption + cfg.Log.LongFile = TrueOption + cfg.Log.ShortFile = TrueOption + cfg.Log.UTC = TrueOption + cfg.VRoot = []string{"/foo", "/bar"} + + _, err := Option("invalid name") + assert.EqualError(t, err, "invalid option name") + for _, name := range OptionNames() { + acc, err := Option(name) + require.NoError(t, err) + assert.NoError(t, acc.Unset(&cfg), name) + } + assert.Equal(t, "", cfg.GitHub.Token) + assert.Equal(t, "", cfg.GitHub.Host) + assert.Equal(t, "", cfg.GitHub.User) + assert.Equal(t, "", cfg.Log.Level) + assert.Equal(t, EmptyBoolOption, cfg.Log.Date) + assert.Equal(t, EmptyBoolOption, cfg.Log.Time) + assert.Equal(t, EmptyBoolOption, cfg.Log.MicroSeconds) + assert.Equal(t, EmptyBoolOption, cfg.Log.LongFile) + assert.Equal(t, EmptyBoolOption, cfg.Log.ShortFile) + assert.Equal(t, EmptyBoolOption, cfg.Log.UTC) + assert.Empty(t, cfg.VRoot) + }) +} diff --git a/config/bool.go b/config/bool.go new file mode 100644 index 00000000..d3509e59 --- /dev/null +++ b/config/bool.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "strings" +) + +type BoolOption string + +var ( + TrueOption = BoolOption("yes") + FalseOption = BoolOption("no") + EmptyBoolOption = BoolOption("") +) + +func (c BoolOption) String() string { + return string(c) +} + +func (c BoolOption) Bool() bool { + return c == TrueOption +} + +// Decode implements the interface `envdecode.Decoder` +func (c *BoolOption) Decode(repl string) error { + switch strings.ToLower(repl) { + case "yes", "no", "": + *c = BoolOption(repl) + return nil + } + return errors.New("invalid type") +} + +// MarshalYAML implements the interface `yaml.Marshaler` +func (c BoolOption) MarshalYAML() (interface{}, error) { + return string(c), nil +} + +// UnmarshalYAML implements the interface `yaml.Unmarshaler` +func (c *BoolOption) UnmarshalYAML(unmarshal func(interface{}) error) error { + var parsed string + if err := unmarshal(&parsed); err != nil { + return err + } + return c.Decode(parsed) +} diff --git a/config/bool_test.go b/config/bool_test.go new file mode 100644 index 00000000..aad3ca96 --- /dev/null +++ b/config/bool_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/joeshaw/envdecode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v2" +) + +func TestBoolOption(t *testing.T) { + assert.True(t, TrueOption.Bool()) + assert.False(t, FalseOption.Bool()) + + type testStruct struct { + Bool BoolOption `env:"BOOL" yaml:"bool,omitempty"` + } + + t.Run("encode to yaml", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{})) + assert.Equal(t, "{}", strings.TrimSpace(buf.String())) + + buf.Reset() + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{Bool: FalseOption})) + assert.Equal(t, `bool: "no"`, strings.TrimSpace(buf.String())) + + buf.Reset() + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{Bool: TrueOption})) + assert.Equal(t, `bool: "yes"`, strings.TrimSpace(buf.String())) + }) + t.Run("decode from YAML", func(t *testing.T) { + var testValue testStruct + require.NoError(t, yaml.Unmarshal([]byte(`{}`), &testValue)) + assert.Equal(t, EmptyBoolOption, testValue.Bool) + + require.NoError(t, yaml.Unmarshal([]byte(`bool: no`), &testValue)) + assert.Equal(t, FalseOption, testValue.Bool) + + require.NoError(t, yaml.Unmarshal([]byte(`bool: yes`), &testValue)) + assert.Equal(t, TrueOption, testValue.Bool) + + require.NoError(t, yaml.Unmarshal([]byte(`bool: ""`), &testValue)) + assert.Equal(t, EmptyBoolOption, testValue.Bool) + + assert.Error(t, yaml.Unmarshal([]byte(`bool: invalid`), &testValue)) + assert.Error(t, yaml.Unmarshal([]byte("bool:\n- invalid"), &testValue)) + }) + t.Run("get from envar", func(t *testing.T) { + var testValue testStruct + resetEnv(t) + require.NoError(t, os.Setenv("BOOL", "no")) + require.NoError(t, envdecode.Decode(&testValue)) + assert.Equal(t, FalseOption, testValue.Bool) + + testValue = testStruct{} + resetEnv(t) + require.NoError(t, os.Setenv("BOOL", "yes")) + require.NoError(t, envdecode.Decode(&testValue)) + assert.Equal(t, TrueOption, testValue.Bool) + }) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..d3ffeabd --- /dev/null +++ b/config/config.go @@ -0,0 +1,98 @@ +package config + +import ( + "context" + "io" + "log" + "os" +) + +// Config holds configuration file values. +type Config struct { + context.Context `yaml:"-"` + Log LogConfig `yaml:"log,omitempty"` + VRoot PathListOption `yaml:"root,omitempty" env:"GOGH_ROOT"` + GitHub GitHubConfig `yaml:"github,omitempty"` +} + +type LogConfig struct { + Level string `yaml:"level,omitempty" env:"GOGH_LOG_LEVEL"` + Date BoolOption `yaml:"date" env:"GOGH_LOG_DATE"` // the date in the local time zone: 2009/01/23 + Time BoolOption `yaml:"time" env:"GOGH_LOG_TIME"` // the time in the local time zone: 01:23:23 + MicroSeconds BoolOption `yaml:"microseconds" env:"GOGH_LOG_MICROSECONDS"` // microsecond resolution: 01:23:23.123123. assumes Ltime. + LongFile BoolOption `yaml:"longfile" env:"GOGH_LOG_LONGFILE"` // full file name and line number: /a/b/c/d.go:23 + ShortFile BoolOption `yaml:"shortfile" env:"GOGH_LOG_SHORTFILE"` // final file name element and line number: d.go:23. overrides Llongfile + UTC BoolOption `yaml:"utc" env:"GOGH_LOG_UTC"` // if Ldate or Ltime is set, use UTC rather than the local time zone +} + +type GitHubConfig struct { + Token string `yaml:"-" env:"GOGH_GITHUB_TOKEN"` + User string `yaml:"user,omitempty" env:"GOGH_GITHUB_USER"` + Host string `yaml:"host,omitempty" env:"GOGH_GITHUB_HOST"` +} + +func (c *Config) Stdin() io.Reader { + return os.Stdin +} + +func (c *Config) Stdout() io.Writer { + return os.Stdout +} + +func (c *Config) Stderr() io.Writer { + return os.Stderr +} + +func (c *Config) GitHubUser() string { + return c.GitHub.User +} + +func (c *Config) GitHubToken() string { + return c.GitHub.Token +} + +func (c *Config) GitHubHost() string { + return c.GitHub.Host +} + +func (c *Config) LogLevel() string { + return c.Log.Level +} + +func (c *Config) LogFlags() int { + var f int + if c.Log.Date.Bool() { + f |= log.Ldate + } + if c.Log.Time.Bool() { + f |= log.Ltime + } + if c.Log.MicroSeconds.Bool() { + f |= log.Lmicroseconds + } + if c.Log.LongFile.Bool() { + f |= log.Llongfile + } + if c.Log.ShortFile.Bool() { + f |= log.Lshortfile + } + if c.Log.UTC.Bool() { + f |= log.LUTC + } + return f +} + +func (c *Config) LogDate() bool { return c.Log.Date.Bool() } +func (c *Config) LogTime() bool { return c.Log.Time.Bool() } +func (c *Config) LogMicroSeconds() bool { return c.Log.MicroSeconds.Bool() } +func (c *Config) LogLongFile() bool { return c.Log.LongFile.Bool() } +func (c *Config) LogShortFile() bool { return c.Log.ShortFile.Bool() } +func (c *Config) LogUTC() bool { return c.Log.UTC.Bool() } + +func (c *Config) Root() []string { + return c.VRoot +} + +func (c *Config) PrimaryRoot() string { + return c.VRoot[0] +} diff --git a/config/get.go b/config/get.go new file mode 100644 index 00000000..45ebeeee --- /dev/null +++ b/config/get.go @@ -0,0 +1,92 @@ +package config + +import ( + "go/build" + "io" + "path/filepath" + "sync" + + "github.com/joeshaw/envdecode" + "github.com/thoas/go-funk" + yaml "gopkg.in/yaml.v2" +) + +var ( + envGoghLogLevel = "GOGH_LOG_LEVEL" + envGoghLogDate = "GOGH_LOG_DATE" + envGoghLogTime = "GOGH_LOG_TIME" + envGoghLogMicroSeconds = "GOGH_LOG_MICROSECONDS" + envGoghLogLongFile = "GOGH_LOG_LONGFILE" + envGoghLogShortFile = "GOGH_LOG_SHORTFILE" + envGoghLogUTC = "GOGH_LOG_UTC" + envGoghGitHubUser = "GOGH_GITHUB_USER" + envGoghGitHubToken = "GOGH_GITHUB_TOKEN" + envGoghGitHubHost = "GOGH_GITHUB_HOST" + envGoghRoot = "GOGH_ROOT" + envNames = []string{ + envGoghLogLevel, + envGoghLogDate, + envGoghLogTime, + envGoghLogMicroSeconds, + envGoghLogLongFile, + envGoghLogShortFile, + envGoghLogUTC, + envGoghGitHubUser, + envGoghGitHubToken, + envGoghGitHubHost, + envGoghRoot, + } +) + +const ( + // DefaultHost is the default host of the GitHub + DefaultHost = "github.com" + DefaultLogLevel = "warn" +) + +var defaultConfig = Config{ + Log: LogConfig{ + Level: DefaultLogLevel, + Time: TrueOption, + }, + GitHub: GitHubConfig{ + Host: DefaultHost, + }, +} + +var initDefaultConfig sync.Once + +func DefaultConfig() *Config { + initDefaultConfig.Do(func() { + gopaths := filepath.SplitList(build.Default.GOPATH) + root := make([]string, 0, len(gopaths)) + for _, gopath := range gopaths { + root = append(root, filepath.Join(gopath, "src")) + } + defaultConfig.VRoot = funk.UniqString(root) + }) + return &defaultConfig +} + +func LoadConfig(r io.Reader) (config *Config, err error) { + config = &Config{} + if err := yaml.NewDecoder(r).Decode(config); err != nil { + return nil, err + } + config.VRoot = funk.UniqString(config.VRoot) + return +} + +func SaveConfig(w io.Writer, config *Config) error { + return yaml.NewEncoder(w).Encode(config) +} + +func GetEnvarConfig() (config *Config, err error) { + config = &Config{} + err = envdecode.Decode(config) + if err == envdecode.ErrNoTargetFieldsAreSet { + err = nil + } + config.VRoot = funk.UniqString(config.VRoot) + return +} diff --git a/config/get_test.go b/config/get_test.go new file mode 100644 index 00000000..ee0b37c8 --- /dev/null +++ b/config/get_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "bytes" + "log" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resetEnv(t *testing.T) { + t.Helper() + for _, key := range envNames { + require.NoError(t, os.Setenv(key, "")) + } +} + +func TestDefaultConfig(t *testing.T) { + resetEnv(t) + cfg := DefaultConfig() + assert.Equal(t, "", cfg.GitHubToken()) + assert.Equal(t, "github.com", cfg.GitHubHost()) + assert.Equal(t, "", cfg.GitHubUser()) + assert.Equal(t, "warn", cfg.LogLevel()) + assert.NotEmpty(t, cfg.Root()) + assert.NotEmpty(t, cfg.PrimaryRoot()) + assert.Equal(t, os.Stderr, cfg.Stderr()) + assert.Equal(t, os.Stdout, cfg.Stdout()) + assert.Equal(t, os.Stdin, cfg.Stdin()) +} + +func TestLoadConfig(t *testing.T) { + t.Run("success", func(t *testing.T) { + resetEnv(t) + cfg, err := LoadConfig(bytes.NewBufferString(` +root: +- /foo +- /bar + +log: + level: trace + date: "yes" + time: "yes" + microseconds: yes + longfile: "yes" + shortfile: "yes" + utc: "yes" + +github: + token: tokenx1 + user: kyoh86 + host: hostx1 +`)) + require.NoError(t, err) + assert.Equal(t, "", cfg.GitHubToken(), "token should not be saved in file") + assert.Equal(t, "hostx1", cfg.GitHubHost()) + assert.Equal(t, "kyoh86", cfg.GitHubUser()) + assert.Equal(t, "trace", cfg.LogLevel()) + assert.Equal(t, log.Ldate|log.Ltime|log.Lmicroseconds|log.Llongfile|log.Lshortfile|log.LUTC, cfg.LogFlags()) + assert.Equal(t, []string{"/foo", "/bar"}, cfg.Root()) + assert.Equal(t, "/foo", cfg.PrimaryRoot()) + assert.Equal(t, os.Stderr, cfg.Stderr()) + assert.Equal(t, os.Stdout, cfg.Stdout()) + }) + t.Run("invalid format", func(t *testing.T) { + resetEnv(t) + _, err := LoadConfig(bytes.NewBufferString(`{`)) + assert.Error(t, err) + }) +} + +func TestSaveConfig(t *testing.T) { + t.Run("success", func(t *testing.T) { + resetEnv(t) + + var buf bytes.Buffer + var cfg Config + cfg.GitHub.Token = "token1" + cfg.GitHub.Host = "hostx1" + cfg.GitHub.User = "kyoh86" + cfg.Log.Level = "trace" + cfg.Log.Date = TrueOption + cfg.Log.Time = FalseOption + cfg.Log.MicroSeconds = TrueOption + cfg.Log.LongFile = TrueOption + cfg.Log.ShortFile = TrueOption + cfg.Log.UTC = TrueOption + cfg.VRoot = []string{"/foo", "/bar"} + + require.NoError(t, SaveConfig(&buf, &cfg)) + + output := buf.String() + assert.Contains(t, output, "root:") + assert.Contains(t, output, "- /foo") + assert.Contains(t, output, "- /bar") + assert.Contains(t, output, "log:") + assert.Contains(t, output, " level: trace") + assert.Contains(t, output, ` date: "yes"`) + assert.Contains(t, output, ` time: "no"`) + assert.Contains(t, output, ` microseconds: "yes"`) + assert.Contains(t, output, ` longfile: "yes"`) + assert.Contains(t, output, ` shortfile: "yes"`) + assert.Contains(t, output, ` utc: "yes"`) + assert.Contains(t, output, "github:") + assert.NotContains(t, output, "tokenx1") + assert.Contains(t, output, " user: kyoh86") + assert.Contains(t, output, " host: hostx1") + }) +} + +func TestGetEnvarConfig(t *testing.T) { + resetEnv(t) + require.NoError(t, os.Setenv(envGoghGitHubToken, "tokenx1")) + require.NoError(t, os.Setenv(envGoghGitHubHost, "hostx1")) + require.NoError(t, os.Setenv(envGoghGitHubUser, "kyoh86")) + require.NoError(t, os.Setenv(envGoghLogLevel, "trace")) + require.NoError(t, os.Setenv(envGoghLogDate, "yes")) + require.NoError(t, os.Setenv(envGoghLogTime, "yes")) + require.NoError(t, os.Setenv(envGoghLogMicroSeconds, "yes")) + require.NoError(t, os.Setenv(envGoghLogLongFile, "yes")) + require.NoError(t, os.Setenv(envGoghLogShortFile, "yes")) + require.NoError(t, os.Setenv(envGoghLogUTC, "yes")) + require.NoError(t, os.Setenv(envGoghRoot, "/foo:/bar:/bar:/foo")) + cfg, err := GetEnvarConfig() + require.NoError(t, err) + assert.Equal(t, "tokenx1", cfg.GitHubToken()) + assert.Equal(t, "hostx1", cfg.GitHubHost()) + assert.Equal(t, "kyoh86", cfg.GitHubUser()) + assert.Equal(t, "trace", cfg.LogLevel()) + assert.Equal(t, log.Ldate|log.Ltime|log.Lmicroseconds|log.Llongfile|log.Lshortfile|log.LUTC, cfg.LogFlags()) + assert.Equal(t, []string{"/foo", "/bar"}, cfg.Root(), "expects roots are not duplicated") + assert.Equal(t, "/foo", cfg.PrimaryRoot()) + assert.Equal(t, os.Stderr, cfg.Stderr()) + assert.Equal(t, os.Stdout, cfg.Stdout()) +} diff --git a/config/merge.go b/config/merge.go new file mode 100644 index 00000000..7d865ad0 --- /dev/null +++ b/config/merge.go @@ -0,0 +1,44 @@ +package config + +func MergeConfig(base *Config, override ...*Config) *Config { + c := *base + for _, o := range override { + c.Log.Level = mergeStringOption(c.Log.Level, o.Log.Level) + c.Log.Date = mergeBoolOption(c.Log.Date, o.Log.Date) + c.Log.Time = mergeBoolOption(c.Log.Time, o.Log.Time) + c.Log.MicroSeconds = mergeBoolOption(c.Log.MicroSeconds, o.Log.MicroSeconds) + c.Log.LongFile = mergeBoolOption(c.Log.LongFile, o.Log.LongFile) + c.Log.ShortFile = mergeBoolOption(c.Log.ShortFile, o.Log.ShortFile) + c.Log.UTC = mergeBoolOption(c.Log.UTC, o.Log.UTC) + c.VRoot = mergePathListOption(c.VRoot, o.VRoot) + c.GitHub.Token = mergeStringOption(c.GitHub.Token, o.GitHub.Token) + c.GitHub.User = mergeStringOption(c.GitHub.User, o.GitHub.User) + c.GitHub.Host = mergeStringOption(c.GitHub.Host, o.GitHub.Host) + } + return &c +} + +func mergeBoolOption(base, override BoolOption) BoolOption { + switch { + case override != EmptyBoolOption: + return override + case base != EmptyBoolOption: + return base + default: + return EmptyBoolOption + } +} + +func mergeStringOption(base, override string) string { + if override != "" { + return override + } + return base +} + +func mergePathListOption(base, override []string) []string { + if len(override) > 0 { + return override + } + return base +} diff --git a/config/merge_test.go b/config/merge_test.go new file mode 100644 index 00000000..e8379816 --- /dev/null +++ b/config/merge_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "log" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeConfig(t *testing.T) { + resetEnv(t) + + require.NoError(t, os.Setenv(envGoghGitHubToken, "tokenx1")) + require.NoError(t, os.Setenv(envGoghGitHubHost, "hostx1")) + require.NoError(t, os.Setenv(envGoghGitHubUser, "kyoh86")) + require.NoError(t, os.Setenv(envGoghLogLevel, "trace")) + require.NoError(t, os.Setenv(envGoghLogDate, "yes")) + require.NoError(t, os.Setenv(envGoghLogTime, "yes")) + require.NoError(t, os.Setenv(envGoghLogMicroSeconds, "yes")) + require.NoError(t, os.Setenv(envGoghLogLongFile, "yes")) + require.NoError(t, os.Setenv(envGoghLogShortFile, "yes")) + require.NoError(t, os.Setenv(envGoghLogUTC, "yes")) + require.NoError(t, os.Setenv(envGoghRoot, "/foo:/bar")) + + cfg1, err := GetEnvarConfig() + require.NoError(t, err) + + t.Run("full overwritten config", func(t *testing.T) { + require.NoError(t, os.Setenv(envGoghGitHubToken, "tokenx2")) + require.NoError(t, os.Setenv(envGoghGitHubHost, "hostx2")) + require.NoError(t, os.Setenv(envGoghGitHubUser, "kyoh87")) + require.NoError(t, os.Setenv(envGoghLogLevel, "debug")) + require.NoError(t, os.Setenv(envGoghLogDate, "no")) + require.NoError(t, os.Setenv(envGoghLogTime, "no")) + require.NoError(t, os.Setenv(envGoghLogMicroSeconds, "no")) + require.NoError(t, os.Setenv(envGoghLogLongFile, "no")) + require.NoError(t, os.Setenv(envGoghLogShortFile, "no")) + require.NoError(t, os.Setenv(envGoghLogUTC, "no")) + require.NoError(t, os.Setenv(envGoghRoot, "/baz:/bux")) + + cfg2, err := GetEnvarConfig() + require.NoError(t, err) + + cfg := MergeConfig(cfg1, cfg2) // prior after config + assert.Equal(t, "tokenx2", cfg.GitHubToken()) + assert.Equal(t, "hostx2", cfg.GitHubHost()) + assert.Equal(t, "kyoh87", cfg.GitHubUser()) + assert.Equal(t, "debug", cfg.LogLevel()) + assert.Equal(t, 0, cfg.LogFlags()) + assert.Equal(t, []string{"/baz", "/bux"}, cfg.Root()) + assert.Equal(t, "/baz", cfg.PrimaryRoot()) + assert.Equal(t, os.Stderr, cfg.Stderr()) + assert.Equal(t, os.Stdout, cfg.Stdout()) + }) + + t.Run("no overwritten config", func(t *testing.T) { + resetEnv(t) + + cfg2, err := GetEnvarConfig() + require.NoError(t, err) + + cfg := MergeConfig(cfg1, cfg2) // prior after config + assert.Equal(t, "tokenx1", cfg.GitHubToken()) + assert.Equal(t, "hostx1", cfg.GitHubHost()) + assert.Equal(t, "kyoh86", cfg.GitHubUser()) + assert.Equal(t, "trace", cfg.LogLevel()) + assert.Equal(t, log.Ldate|log.Ltime|log.Lmicroseconds|log.Llongfile|log.Lshortfile|log.LUTC, cfg.LogFlags()) + assert.Equal(t, []string{"/foo", "/bar"}, cfg.Root()) + assert.Equal(t, "/foo", cfg.PrimaryRoot()) + assert.Equal(t, os.Stderr, cfg.Stderr()) + assert.Equal(t, os.Stdout, cfg.Stdout()) + }) + + resetEnv(t) + assert.Equal(t, EmptyBoolOption, mergeBoolOption(EmptyBoolOption, EmptyBoolOption)) + assert.Equal(t, TrueOption, mergeBoolOption(TrueOption, EmptyBoolOption)) + assert.Equal(t, FalseOption, mergeBoolOption(FalseOption, EmptyBoolOption)) + assert.Equal(t, TrueOption, mergeBoolOption(EmptyBoolOption, TrueOption)) + assert.Equal(t, FalseOption, mergeBoolOption(TrueOption, FalseOption)) + assert.Equal(t, TrueOption, mergeBoolOption(FalseOption, TrueOption)) +} diff --git a/config/path_list.go b/config/path_list.go new file mode 100644 index 00000000..435d705c --- /dev/null +++ b/config/path_list.go @@ -0,0 +1,28 @@ +package config + +import ( + "path/filepath" +) + +type PathListOption []string + +// Decode implements the interface `envdecode.Decoder` +func (c *PathListOption) Decode(repl string) error { + *c = filepath.SplitList(repl) + return nil +} + +// MarshalYAML implements the interface `yaml.Marshaler` +func (c PathListOption) MarshalYAML() (interface{}, error) { + return []string(c), nil +} + +// UnmarshalYAML implements the interface `yaml.Unmarshaler` +func (c *PathListOption) UnmarshalYAML(unmarshal func(interface{}) error) error { + var parsed []string + if err := unmarshal(&parsed); err != nil { + return err + } + *c = parsed + return nil +} diff --git a/config/path_list_test.go b/config/path_list_test.go new file mode 100644 index 00000000..23d0de38 --- /dev/null +++ b/config/path_list_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/joeshaw/envdecode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v2" +) + +func TestPathListOption(t *testing.T) { + + type testStruct struct { + PathList PathListOption `env:"PATH_LIST" yaml:"paths,omitempty"` + } + + t.Run("encode to yaml", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{})) + assert.Equal(t, "{}", strings.TrimSpace(buf.String())) + + buf.Reset() + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{PathList: PathListOption{}})) + assert.Equal(t, "{}", strings.TrimSpace(buf.String())) + + buf.Reset() + require.NoError(t, yaml.NewEncoder(&buf).Encode(testStruct{PathList: PathListOption{"/foo", "/bar"}})) + assert.Equal(t, "paths:\n- /foo\n- /bar", strings.TrimSpace(buf.String())) + }) + t.Run("decode from YAML", func(t *testing.T) { + var testValue testStruct + require.NoError(t, yaml.Unmarshal([]byte(`{}`), &testValue)) + assert.Equal(t, PathListOption(nil), testValue.PathList) + + require.NoError(t, yaml.Unmarshal([]byte(`paths: []`), &testValue)) + assert.Equal(t, PathListOption{}, testValue.PathList) + + require.NoError(t, yaml.Unmarshal([]byte(`paths: ["/foo", "/bar"]`), &testValue)) + assert.Equal(t, PathListOption{"/foo", "/bar"}, testValue.PathList) + + assert.Error(t, yaml.Unmarshal([]byte(`paths: invalid`), &testValue)) + }) + t.Run("get from envar", func(t *testing.T) { + var testValue testStruct + resetEnv(t) + require.NoError(t, os.Setenv("PATH_LIST", "/foo:/bar")) + require.NoError(t, envdecode.Decode(&testValue)) + assert.Equal(t, PathListOption{"/foo", "/bar"}, testValue.PathList) + }) +} diff --git a/go.mod b/go.mod index c3d70952..4187f009 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/kyoh86/gogh require ( github.com/BurntSushi/toml v0.3.1 // indirect github.com/alecthomas/kingpin v0.0.0-20190313214811-7613e5d4efd9 - github.com/atotto/clipboard v0.1.1 // indirect + github.com/atotto/clipboard v0.1.2 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/comail/colog v0.0.0-20160416085026-fba8e7b1f46c github.com/github/hub v2.10.0+incompatible github.com/google/go-github/v24 v24.0.1 + github.com/joeshaw/envdecode v0.0.0-20180312135643-c9e015854467 github.com/karrick/godirwalk v1.8.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/pretty v0.1.0 // indirect - github.com/mattn/go-colorable v0.0.9 // indirect - github.com/mattn/go-isatty v0.0.4 // indirect - github.com/mitchellh/go-homedir v1.0.0 // indirect - github.com/ogier/pflag v0.0.1 // indirect + github.com/kyoh86/xdg v1.0.0 + github.com/mattn/go-colorable v0.1.1 // indirect + github.com/mattn/go-isatty v0.0.7 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/pkg/errors v0.8.0 github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709 - golang.org/x/net v0.0.0-20190311183353-d8887717615a // indirect + github.com/thoas/go-funk v0.0.0-20190328084834-b6996520715f golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 // indirect - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect - gopkg.in/yaml.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index 9a53eec1..5770dc66 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5Vpd github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/atotto/clipboard v0.1.1 h1:WSoEbAS70E5gw8FbiqFlp69MGsB6dUb4l+0AGGLiVGw= -github.com/atotto/clipboard v0.1.1/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/comail/colog v0.0.0-20160416085026-fba8e7b1f46c h1:bzYQ6WpR+t35/y19HUkolcg7SYeWZ15IclC9Z4naGHI= @@ -15,8 +15,6 @@ github.com/comail/colog v0.0.0-20160416085026-fba8e7b1f46c/go.mod h1:1WwgAwMKQLY github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/github/hub v2.6.0+incompatible h1:dnPzCgdKQn7MPbQ1FDdVIyc9J+9RjS1wc2hkPW5QJq8= -github.com/github/hub v2.6.0+incompatible/go.mod h1:zQrzJEdze2hfWJDgktd/L6sROjAdCThFrzjbxw4keTs= github.com/github/hub v2.10.0+incompatible h1:gK3M/y1ZD/Mc0ytSDykzL8C9q8k7pVTTiEa+1SanJZk= github.com/github/hub v2.10.0+incompatible/go.mod h1:zQrzJEdze2hfWJDgktd/L6sROjAdCThFrzjbxw4keTs= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= @@ -27,6 +25,8 @@ github.com/google/go-github/v24 v24.0.1 h1:KCt1LjMJEey1qvPXxa9SjaWxwTsCWSq6p2Ju5 github.com/google/go-github/v24 v24.0.1/go.mod h1:CRqaW1Uns1TCkP0wqTpxYyRxRjxwvKU/XSS44u6X74M= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/joeshaw/envdecode v0.0.0-20180312135643-c9e015854467 h1:REcMVvHDwEBFDEUrphtsx+/LqVKkKfcWxnAuVKFzSWk= +github.com/joeshaw/envdecode v0.0.0-20180312135643-c9e015854467/go.mod h1:Q+alOFAXgW5SrcfMPt/G4B2oN+qEcQRJjkn/f4mKL04= github.com/karrick/godirwalk v1.8.0 h1:ycpSqVon/QJJoaT1t8sae0tp1Stg21j+dyuS7OoagcA= github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -36,14 +36,15 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= -github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= +github.com/kyoh86/xdg v1.0.0 h1:TD1layQ0epNApNwGRblnQnT3S/2UH/gCQN1cmXWotvE= +github.com/kyoh86/xdg v1.0.0/go.mod h1:Z5mDqe0fxyxn3W2yTxsBAOQqIrXADQIh02wrTnaRM38= +github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -53,25 +54,22 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709 h1:Ko2LQMrRU+Oy/+EDBwX7eZ2jp3C47eDBB8EIhKTun+I= github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/thoas/go-funk v0.0.0-20190328084834-b6996520715f h1:hucXnlEXfHvrY7vI9hP1bJX/olUUt5a6u3QvxXrtANw= +github.com/thoas/go-funk v0.0.0-20190328084834-b6996520715f/go.mod h1:mlR+dHGb+4YgXkf13rkQTuzrneeHANxOm6+ZnEV9HsA= +golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac h1:7d7lG9fHOLdL6jZPtnV4LpI41SbohIJ1Atq7U991dMg= golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d h1:g9qWBGx4puODJTMVyoPrpoxPFgVGd+z1DZwjfRu4d0I= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180824143301-4910a1d54f87 h1:GqwDwfvIpC33dK9bA1fD+JiDUNsuAiQiEkpHqUKze4o= golang.org/x/sys v0.0.0-20180824143301-4910a1d54f87/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/gogh/context.go b/gogh/context.go index f3cbb1a7..1ae25760 100644 --- a/gogh/context.go +++ b/gogh/context.go @@ -2,172 +2,26 @@ package gogh import ( "context" - "fmt" - "go/build" "io" - "os" - "path/filepath" - "strings" ) // Context holds configurations and environments type Context interface { context.Context + Stdin() io.Reader Stdout() io.Writer Stderr() io.Writer - UserName() string + GitHubUser() string GitHubToken() string GitHubHost() string LogLevel() string - Roots() []string + LogFlags() int // log.Lxxx flags + LogDate() bool + LogTime() bool + LogMicroSeconds() bool + LogLongFile() bool + LogShortFile() bool + LogUTC() bool + Root() []string PrimaryRoot() string - GHEHosts() []string -} - -// CurrentContext get current context from OS envars and Git configurations -func CurrentContext(ctx context.Context) (Context, error) { - userName, err := getUserName() - if err != nil { - return nil, err - } - gitHubToken := getGitHubToken() - gitHubHost := getGitHubHost() - logLevel := getLogLevel() - gheHosts := getGHEHosts() - roots, err := getRoots() - if err != nil { - return nil, err - } - return &implContext{ - Context: ctx, - stdout: os.Stdout, - stderr: os.Stderr, - userName: userName, - gitHubToken: gitHubToken, - gitHubHost: gitHubHost, - logLevel: logLevel, - roots: roots, - gheHosts: gheHosts, - }, nil -} - -type implContext struct { - context.Context - stdout io.Writer - stderr io.Writer - userName string - gitHubToken string - gitHubHost string - logLevel string - roots []string - gheHosts []string -} - -func (c *implContext) Stdout() io.Writer { - return c.stdout -} - -func (c *implContext) Stderr() io.Writer { - return c.stderr -} - -func (c *implContext) UserName() string { - return c.userName -} - -func (c *implContext) GitHubToken() string { - return c.gitHubToken -} - -func (c *implContext) GitHubHost() string { - return c.gitHubHost -} - -func (c *implContext) LogLevel() string { - return c.logLevel -} - -func (c *implContext) Roots() []string { - return c.roots -} - -func (c *implContext) PrimaryRoot() string { - rts := c.Roots() - return rts[0] -} - -func (c *implContext) GHEHosts() []string { - return c.gheHosts -} - -func getConf(envNames ...string) string { - for _, n := range envNames { - if val := os.Getenv(n); val != "" { - return val - } - } - return "" -} - -func getGitHubToken() string { - return getConf(envGoghGitHubToken, envGitHubToken) -} - -func getGitHubHost() string { - return getConf(envGoghGitHubHost, envGitHubHost) -} - -func getUserName() (string, error) { - name := getConf(envGoghGitHubUser, envGitHubUser, envUserName) - if name == "" { - // Make the error if it does not match any pattern - return name, fmt.Errorf("failed to find user name. set %s in environment variable", envGoghGitHubUser) - } - if err := ValidateOwner(name); err != nil { - return name, err - } - return name, nil -} - -func getLogLevel() string { - if ll := os.Getenv(envLogLevel); ll != "" { - return ll - } - return "warn" // default: warn -} - -func getRoots() ([]string, error) { - var roots []string - envRoot := os.Getenv(envRoot) - if envRoot == "" { - gopaths := filepath.SplitList(build.Default.GOPATH) - roots = make([]string, 0, len(gopaths)) - for _, gopath := range gopaths { - roots = append(roots, filepath.Join(gopath, "src")) - } - } else { - roots = filepath.SplitList(envRoot) - } - - for i, v := range roots { - path := filepath.Clean(v) - _, err := os.Stat(path) - switch { - case err == nil: - roots[i], err = filepath.EvalSymlinks(path) - if err != nil { - return nil, err - } - case os.IsNotExist(err): - roots[i] = path - default: - return nil, err - } - } - - return unique(roots), nil -} - -func getGHEHosts() []string { - return unique(strings.Split(os.Getenv(envGHEHosts), " ")) } diff --git a/gogh/context_env.go b/gogh/context_env.go deleted file mode 100644 index 01dedc4b..00000000 --- a/gogh/context_env.go +++ /dev/null @@ -1,25 +0,0 @@ -package gogh - -var ( - envLogLevel = "GOGH_LOG_LEVEL" - envGoghGitHubUser = "GOGH_GITHUB_USER" - envGoghGitHubToken = "GOGH_GITHUB_TOKEN" - envGoghGitHubHost = "GOGH_GITHUB_HOST" - envGitHubUser = "GITHUB_USER" - envGitHubToken = "GITHUB_TOKEN" - envGitHubHost = "GITHUB_HOST" - envGHEHosts = "GOGH_GHE_HOST" - envRoot = "GOGH_ROOT" - envNames = []string{ - envLogLevel, - envGoghGitHubUser, - envGoghGitHubToken, - envGoghGitHubHost, - envGitHubUser, - envGitHubToken, - envGitHubHost, - envUserName, - envGHEHosts, - envRoot, - } -) diff --git a/gogh/context_env_others.go b/gogh/context_env_others.go deleted file mode 100644 index feeaa2ed..00000000 --- a/gogh/context_env_others.go +++ /dev/null @@ -1,7 +0,0 @@ -// +build !windows - -package gogh - -var ( - envUserName = "USER" -) diff --git a/gogh/context_env_windows.go b/gogh/context_env_windows.go deleted file mode 100644 index 22e035f5..00000000 --- a/gogh/context_env_windows.go +++ /dev/null @@ -1,3 +0,0 @@ -package gogh - -var envUserName = "USERNAME" diff --git a/gogh/context_test.go b/gogh/context_test.go deleted file mode 100644 index d4bff9fb..00000000 --- a/gogh/context_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package gogh - -import ( - "context" - "go/build" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestContext(t *testing.T) { - resetEnv := func(t *testing.T) { - t.Helper() - for _, key := range envNames { - require.NoError(t, os.Setenv(key, "")) - } - } - - t.Run("get context without roots", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envGoghGitHubToken, "tokenx1")) - require.NoError(t, os.Setenv(envGoghGitHubHost, "hostx1")) - require.NoError(t, os.Setenv(envGoghGitHubUser, "kyoh86")) - require.NoError(t, os.Setenv(envLogLevel, "trace")) - require.NoError(t, os.Setenv(envGHEHosts, "example.com example.com:9999")) - - ctx, err := CurrentContext(context.Background()) - require.NoError(t, err) - assert.Equal(t, "tokenx1", ctx.GitHubToken()) - assert.Equal(t, "hostx1", ctx.GitHubHost()) - assert.Equal(t, "kyoh86", ctx.UserName()) - assert.Equal(t, "trace", ctx.LogLevel()) - assert.Equal(t, []string{"example.com", "example.com:9999"}, ctx.GHEHosts()) - }) - - t.Run("get GitHub token", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envGitHubToken, "tokenx2")) - assert.Equal(t, "tokenx2", getGitHubToken()) - }) - - t.Run("get GitHub host", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envGitHubHost, "hostx2")) - assert.Equal(t, "hostx2", getGitHubHost()) - }) - - t.Run("get GitHub user name", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envGitHubUser, "kyoh87")) - name, err := getUserName() - require.NoError(t, err) - assert.Equal(t, "kyoh87", name) - }) - - t.Run("get OS user name", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envUserName, "kyoh88")) - name, err := getUserName() - require.NoError(t, err) - assert.Equal(t, "kyoh88", name) - }) - - t.Run("expect to fail to get user name from anywhere", func(t *testing.T) { - resetEnv(t) - _, err := CurrentContext(context.Background()) - require.EqualError(t, err, "failed to find user name. set GOGH_GITHUB_USER in environment variable") - }) - - t.Run("expect to get invalid user name", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envUserName, "-kyoh88")) - _, err := getUserName() - require.NotNil(t, err) - }) - - t.Run("expect to fail to get log level from anywhere", func(t *testing.T) { - resetEnv(t) - assert.Equal(t, "warn", getLogLevel()) - }) - - t.Run("get root paths", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envRoot, "/foo:/bar")) - rts, err := getRoots() - assert.NoError(t, err) - assert.Equal(t, []string{"/foo", "/bar"}, rts) - }) - - t.Run("get root paths from GOPATH", func(t *testing.T) { - resetEnv(t) - rts, err := getRoots() - assert.NoError(t, err) - assert.Equal(t, []string{filepath.Join(build.Default.GOPATH, "src")}, rts) - }) - - t.Run("expects roots are not duplicated", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envRoot, "/foo:/bar:/bar:/foo")) - rts, err := getRoots() - assert.NoError(t, err) - assert.Equal(t, []string{"/foo", "/bar"}, rts) - }) - - t.Run("expects GHE hosts are not duplicated", func(t *testing.T) { - resetEnv(t) - require.NoError(t, os.Setenv(envGHEHosts, "example.com example.com:9999 example.com:9999 example.com")) - assert.Equal(t, []string{"example.com", "example.com:9999"}, getGHEHosts()) - }) -} diff --git a/gogh/formatter.go b/gogh/formatter.go index cb093b8e..dcd08249 100644 --- a/gogh/formatter.go +++ b/gogh/formatter.go @@ -8,6 +8,7 @@ import ( // ProjectListFormatter holds project list to print them. type ProjectListFormatter interface { Add(*Project) + Len() int PrintAll(io.Writer, string) error } @@ -87,6 +88,10 @@ func (f *shortListFormatter) Add(r *Project) { f.list = append(f.list, r) } +func (f *shortListFormatter) Len() int { + return len(f.list) +} + func (f *shortListFormatter) PrintAll(w io.Writer, sep string) error { for _, project := range f.list { if _, err := fmt.Fprint(w, f.shortName(project)+sep); err != nil { @@ -114,6 +119,10 @@ func (f *simpleCollector) Add(r *Project) { f.list = append(f.list, r) } +func (f *simpleCollector) Len() int { + return len(f.list) +} + type fullPathFormatter struct { *simpleCollector } diff --git a/gogh/formatter_test.go b/gogh/formatter_test.go index 39c64689..f711b620 100644 --- a/gogh/formatter_test.go +++ b/gogh/formatter_test.go @@ -2,17 +2,18 @@ package gogh import ( "bytes" - "errors" "io/ioutil" "testing" + "github.com/kyoh86/gogh/internal/context" + "github.com/kyoh86/gogh/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFormatter(t *testing.T) { t.Run("dry run formatters", func(t *testing.T) { - project, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/gogh") + project, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/gogh") require.NoError(t, err) for _, f := range ProjectListFormats() { formatter, err := ProjectListFormat(f).Formatter() @@ -23,83 +24,83 @@ func TestFormatter(t *testing.T) { }) t.Run("rel path formatter", func(t *testing.T) { - project1, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project1, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) - project2, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/bar") + project2, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/bar") require.NoError(t, err) formatter, err := ProjectListFormatRelPath.Formatter() require.NoError(t, err) formatter.Add(project1) formatter.Add(project2) + assert.Equal(t, 2, formatter.Len()) var buf bytes.Buffer require.NoError(t, formatter.PrintAll(&buf, ":")) assert.Equal(t, `github.com/kyoh86/foo:github.com/kyoh86/bar:`, buf.String()) }) t.Run("writer error by rel path formatter", func(t *testing.T) { - project, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) formatter, err := ProjectListFormatRelPath.Formatter() require.NoError(t, err) formatter.Add(project) - require.EqualError(t, formatter.PrintAll(&invalidWriter{}, ""), "invalid writer") + require.EqualError(t, formatter.PrintAll(testutil.DefaultErrorWriter, ""), "error writer") }) t.Run("full path formatter", func(t *testing.T) { - project1, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project1, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) - project2, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/bar") + project2, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/bar") require.NoError(t, err) formatter, err := ProjectListFormatFullPath.Formatter() require.NoError(t, err) formatter.Add(project1) formatter.Add(project2) - var buf bytes.Buffer - require.NoError(t, formatter.PrintAll(&buf, ":")) - assert.Equal(t, `/go/src/github.com/kyoh86/foo:/go/src/github.com/kyoh86/bar:`, buf.String()) + assert.Equal(t, 2, formatter.Len()) }) t.Run("writer error by full path formatter", func(t *testing.T) { - project, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) formatter, err := ProjectListFormatFullPath.Formatter() require.NoError(t, err) formatter.Add(project) - require.EqualError(t, formatter.PrintAll(&invalidWriter{}, ""), "invalid writer") + require.EqualError(t, formatter.PrintAll(testutil.DefaultErrorWriter, ""), "error writer") }) t.Run("url formatter", func(t *testing.T) { - project1, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project1, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) - project2, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/bar") + project2, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/bar") require.NoError(t, err) formatter, err := ProjectListFormatURL.Formatter() require.NoError(t, err) formatter.Add(project1) formatter.Add(project2) + assert.Equal(t, 2, formatter.Len()) var buf bytes.Buffer require.NoError(t, formatter.PrintAll(&buf, ":")) assert.Equal(t, `https://github.com/kyoh86/foo:https://github.com/kyoh86/bar:`, buf.String()) }) t.Run("writer error by url formatter", func(t *testing.T) { - project, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) formatter, err := ProjectListFormatURL.Formatter() require.NoError(t, err) formatter.Add(project) - require.EqualError(t, formatter.PrintAll(&invalidWriter{}, ""), "invalid writer") + require.EqualError(t, formatter.PrintAll(testutil.DefaultErrorWriter, ""), "error writer") }) t.Run("short formatter", func(t *testing.T) { - project1, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project1, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) - project2, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/bar") + project2, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/bar") require.NoError(t, err) - project3, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh87/bar") + project3, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh87/bar") require.NoError(t, err) - project4, err := parseProject(&implContext{gheHosts: []string{"example.com"}}, "/go/src", "/go/src/example.com/kyoh86/bar") + project4, err := parseProject(&context.MockContext{MGitHubHost: "example.com"}, "/go/src", "/go/src/example.com/kyoh86/bar") require.NoError(t, err) - project5, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/baz") + project5, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/baz") require.NoError(t, err) - project6, err := parseProject(&implContext{}, "/foo", "/foo/github.com/kyoh86/baz") + project6, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/foo", "/foo/github.com/kyoh86/baz") require.NoError(t, err) formatter, err := ProjectListFormatShort.Formatter() require.NoError(t, err) @@ -109,17 +110,18 @@ func TestFormatter(t *testing.T) { formatter.Add(project4) formatter.Add(project5) formatter.Add(project6) + assert.Equal(t, 6, formatter.Len()) var buf bytes.Buffer require.NoError(t, formatter.PrintAll(&buf, ":")) assert.Equal(t, `foo:github.com/kyoh86/bar:kyoh87/bar:example.com/kyoh86/bar:/go/src/github.com/kyoh86/baz:/foo/github.com/kyoh86/baz:`, buf.String()) }) t.Run("writer error by short formatter", func(t *testing.T) { - project, err := parseProject(&implContext{}, "/go/src", "/go/src/github.com/kyoh86/foo") + project, err := parseProject(&context.MockContext{MGitHubHost: "github.com"}, "/go/src", "/go/src/github.com/kyoh86/foo") require.NoError(t, err) formatter, err := ProjectListFormatShort.Formatter() require.NoError(t, err) formatter.Add(project) - require.EqualError(t, formatter.PrintAll(&invalidWriter{}, ""), "invalid writer") + require.EqualError(t, formatter.PrintAll(testutil.DefaultErrorWriter, ""), "error writer") }) t.Run("invalid formatter", func(t *testing.T) { @@ -128,10 +130,3 @@ func TestFormatter(t *testing.T) { }) } - -type invalidWriter struct { -} - -func (w *invalidWriter) Write([]byte) (int, error) { - return 0, errors.New("invalid writer") -} diff --git a/gogh/local.go b/gogh/local.go index 107f7e79..e21ceee6 100644 --- a/gogh/local.go +++ b/gogh/local.go @@ -27,6 +27,15 @@ var ( // FindProject will find a project (local repository) that matches exactly. func FindProject(ctx Context, repo *Repo) (*Project, error) { + return findProject(ctx, repo, Walk) +} + +// FindProjectInPrimary will find a project (local repository) that matches exactly. +func FindProjectInPrimary(ctx Context, repo *Repo) (*Project, error) { + return findProject(ctx, repo, WalkInPrimary) +} + +func findProject(ctx Context, repo *Repo, walker Walker) (*Project, error) { if err := CheckRepoHost(ctx, repo); err != nil { return nil, err } @@ -34,7 +43,7 @@ func FindProject(ctx Context, repo *Repo) (*Project, error) { var project *Project // Find existing repository first - if err := Walk(ctx, func(p *Project) error { + if err := walker(ctx, func(p *Project) error { if p.RelPath == relPath { project = p return filepath.SkipDir @@ -53,7 +62,16 @@ func FindProject(ctx Context, repo *Repo) (*Project, error) { // FindOrNewProject will find a project (local repository) that matches exactly or create new one. func FindOrNewProject(ctx Context, repo *Repo) (*Project, error) { - switch p, err := FindProject(ctx, repo); err { + return findOrNewProject(ctx, repo, Walk) +} + +// FindOrNewProjectInPrimary will find a project (local repository) that matches exactly or create new one. +func FindOrNewProjectInPrimary(ctx Context, repo *Repo) (*Project, error) { + return findOrNewProject(ctx, repo, WalkInPrimary) +} + +func findOrNewProject(ctx Context, repo *Repo, walker Walker) (*Project, error) { + switch p, err := findProject(ctx, repo, walker); err { case ProjectNotFound: // No repository found, returning new one return NewProject(ctx, repo) @@ -161,7 +179,7 @@ func parseProject(ctx Context, root string, fullPath string) (*Project, error) { if len(pathParts) != 3 { return nil, errors.New("not supported project path") } - if err := ValidateHost(ctx, pathParts[0]); err != nil { + if err := SupportedHost(ctx, pathParts[0]); err != nil { return nil, err } if err := ValidateOwner(pathParts[1]); err != nil { @@ -185,7 +203,7 @@ func WalkInPrimary(ctx Context, callback WalkFunc) error { // Walk thorugh projects (local repositories) in gogh.root directories func Walk(ctx Context, callback WalkFunc) error { - for _, root := range ctx.Roots() { + for _, root := range ctx.Root() { if err := walkInPath(ctx, root, callback); err != nil { return err } diff --git a/gogh/local_test.go b/gogh/local_test.go index 733b7ec1..31dbb6a1 100644 --- a/gogh/local_test.go +++ b/gogh/local_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/kyoh86/gogh/internal/context" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -14,7 +15,7 @@ import ( func TestParseProject(t *testing.T) { tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") require.NoError(t, err) - ctx := implContext{roots: []string{tmp}} + ctx := context.MockContext{MRoot: []string{tmp}, MGitHubHost: "github.com"} t.Run("in primary root", func(t *testing.T) { path := filepath.Join(tmp, "github.com", "kyoh86", "gogh") @@ -29,7 +30,7 @@ func TestParseProject(t *testing.T) { tmp2, err := ioutil.TempDir(os.TempDir(), "gogh-test2") require.NoError(t, err) ctx := ctx - ctx.roots = append(ctx.roots, tmp2) + ctx.MRoot = append(ctx.MRoot, tmp2) path := filepath.Join(tmp2, "github.com", "kyoh86", "gogh") p, err := parseProject(&ctx, tmp2, path) require.NoError(t, err) @@ -38,6 +39,12 @@ func TestParseProject(t *testing.T) { assert.False(t, p.IsInPrimaryRoot(&ctx)) }) + t.Run("expect to fail to parse relative path", func(t *testing.T) { + r, err := parseProject(&ctx, tmp, "./github.com/kyoh86/gogh/gogh") + assert.NotNil(t, err) + assert.Nil(t, r) + }) + t.Run("expect to fail to parse unsupported depth", func(t *testing.T) { r, err := parseProject(&ctx, tmp, filepath.Join(tmp, "github.com/kyoh86/gogh/gogh")) assert.NotNil(t, err) @@ -65,22 +72,45 @@ func TestParseProject(t *testing.T) { } func TestFindOrNewProject(t *testing.T) { - tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") + tmp1, err := ioutil.TempDir(os.TempDir(), "gogh-test") + require.NoError(t, err) + defer os.RemoveAll(tmp1) + tmp2, err := ioutil.TempDir(os.TempDir(), "gogh-test") require.NoError(t, err) - ctx := implContext{roots: []string{tmp}, userName: "kyoh86"} + defer os.RemoveAll(tmp2) + ctx := context.MockContext{MRoot: []string{tmp1, tmp2}, MGitHubUser: "kyoh86", MGitHubHost: "github.com"} - path := filepath.Join(tmp, "github.com", "kyoh86", "gogh") + path := filepath.Join(tmp1, "github.com", "kyoh86", "gogh") t.Run("not existing repository", func(t *testing.T) { p, err := FindOrNewProject(&ctx, parseURL(t, "ssh://git@github.com/kyoh86/gogh.git")) require.NoError(t, err) assert.Equal(t, path, p.FullPath) + assert.False(t, p.Exists) + assert.Equal(t, []string{"gogh", "kyoh86/gogh", "github.com/kyoh86/gogh"}, p.Subpaths()) + }) + t.Run("not existing repository (in primary)", func(t *testing.T) { + // Create same name repository in other root + inOther := filepath.Join(tmp2, "github.com", "kyoh86", "gogh", ".git") + require.NoError(t, os.MkdirAll(inOther, 0755)) + defer os.RemoveAll(inOther) + p, err := FindOrNewProjectInPrimary(&ctx, parseURL(t, "ssh://git@github.com/kyoh86/gogh.git")) + require.NoError(t, err) + assert.Equal(t, path, p.FullPath) + assert.False(t, p.Exists) assert.Equal(t, []string{"gogh", "kyoh86/gogh", "github.com/kyoh86/gogh"}, p.Subpaths()) }) t.Run("not existing repository with FindProject", func(t *testing.T) { _, err := FindProject(&ctx, parseURL(t, "ssh://git@github.com/kyoh86/gogh.git")) assert.EqualError(t, err, "project not found") }) + t.Run("not existing repository with FindProjectInPrimary", func(t *testing.T) { + inOther := filepath.Join(tmp2, "github.com", "kyoh86", "gogh", ".git") + require.NoError(t, os.MkdirAll(inOther, 0755)) + defer os.RemoveAll(inOther) + _, err := FindProjectInPrimary(&ctx, parseURL(t, "ssh://git@github.com/kyoh86/gogh.git")) + assert.EqualError(t, err, "project not found") + }) t.Run("not supported host URL by FindProject", func(t *testing.T) { _, err := FindOrNewProject(&ctx, parseURL(t, "ssh://git@example.com/kyoh86/gogh.git")) assert.EqualError(t, err, `not supported host: "example.com"`) @@ -93,11 +123,16 @@ func TestFindOrNewProject(t *testing.T) { _, err := NewProject(&ctx, parseURL(t, "ssh://git@example.com/kyoh86/gogh.git")) assert.EqualError(t, err, `not supported host: "example.com"`) }) + t.Run("fail with invalid root", func(t *testing.T) { + ctx := context.MockContext{MRoot: []string{"/\x00"}, MGitHubUser: "kyoh86", MGitHubHost: "github.com"} + _, err := FindOrNewProject(&ctx, parseURL(t, "ssh://git@github.com/kyoh86/gogh.git")) + assert.Error(t, err) + }) t.Run("existing repository", func(t *testing.T) { // Create same name repository - require.NoError(t, os.MkdirAll(filepath.Join(tmp, "github.com", "kyoh85", "gogh", ".git"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp1, "github.com", "kyoh85", "gogh", ".git"), 0755)) // Create different name repository - require.NoError(t, os.MkdirAll(filepath.Join(tmp, "github.com", "kyoh86", "foo", ".git"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp1, "github.com", "kyoh86", "foo", ".git"), 0755)) // Create target repository require.NoError(t, os.MkdirAll(filepath.Join(path, ".git"), 0755)) defer func() { @@ -120,7 +155,7 @@ func TestFindOrNewProject(t *testing.T) { t.Run("shortest pricese name (name only)", func(t *testing.T) { p, err := FindOrNewProject(&ctx, parseURL(t, "foo")) require.NoError(t, err) - assert.Equal(t, filepath.Join(tmp, "github.com", "kyoh86", "foo"), p.FullPath) + assert.Equal(t, filepath.Join(tmp1, "github.com", "kyoh86", "foo"), p.FullPath) assert.Equal(t, []string{"foo", "kyoh86/foo", "github.com/kyoh86/foo"}, p.Subpaths()) }) }) @@ -142,13 +177,13 @@ func TestWalk(t *testing.T) { } t.Run("Not existing root", func(t *testing.T) { t.Run("primary root", func(t *testing.T) { - ctx := implContext{roots: []string{"/that/will/never/exist"}} + ctx := context.MockContext{MRoot: []string{"/that/will/never/exist"}} require.NoError(t, Walk(&ctx, neverCalled(t))) }) t.Run("secondary root", func(t *testing.T) { tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") require.NoError(t, err) - ctx := implContext{roots: []string{tmp, "/that/will/never/exist"}} + ctx := context.MockContext{MRoot: []string{tmp, "/that/will/never/exist"}} require.NoError(t, Walk(&ctx, neverCalled(t))) }) }) @@ -158,7 +193,7 @@ func TestWalk(t *testing.T) { tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") require.NoError(t, err) require.NoError(t, ioutil.WriteFile(filepath.Join(tmp, "foo"), nil, 0644)) - ctx := implContext{roots: []string{filepath.Join(tmp, "foo")}} + ctx := context.MockContext{MRoot: []string{filepath.Join(tmp, "foo")}} require.NoError(t, Walk(&ctx, neverCalled(t))) require.NoError(t, WalkInPrimary(&ctx, neverCalled(t))) }) @@ -166,7 +201,7 @@ func TestWalk(t *testing.T) { tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") require.NoError(t, err) require.NoError(t, ioutil.WriteFile(filepath.Join(tmp, "foo"), nil, 0644)) - ctx := implContext{roots: []string{tmp, filepath.Join(tmp, "foo")}} + ctx := context.MockContext{MRoot: []string{tmp, filepath.Join(tmp, "foo")}} require.NoError(t, Walk(&ctx, neverCalled(t))) require.NoError(t, WalkInPrimary(&ctx, neverCalled(t))) }) @@ -178,7 +213,7 @@ func TestWalk(t *testing.T) { path := filepath.Join(tmp, "github.com", "kyoh--86", "gogh") require.NoError(t, os.MkdirAll(filepath.Join(path, ".git"), 0755)) - ctx := implContext{roots: []string{tmp, filepath.Join(tmp)}} + ctx := context.MockContext{MRoot: []string{tmp, filepath.Join(tmp)}} assert.NoError(t, Walk(&ctx, neverCalled(t))) }) @@ -189,7 +224,7 @@ func TestWalk(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(path, ".git"), 0755)) require.NoError(t, ioutil.WriteFile(filepath.Join(tmp, "foo"), nil, 0644)) - ctx := implContext{roots: []string{tmp, filepath.Join(tmp, "foo")}} + ctx := context.MockContext{MRoot: []string{tmp, filepath.Join(tmp, "foo")}, MGitHubHost: "github.com"} err = errors.New("sample error") assert.EqualError(t, Walk(&ctx, func(p *Project) error { assert.Equal(t, path, p.FullPath) @@ -206,7 +241,7 @@ func TestList_Symlink(t *testing.T) { symDir, err := ioutil.TempDir("", "") require.NoError(t, err) - ctx := &implContext{roots: []string{root}} + ctx := &context.MockContext{MRoot: []string{root}, MGitHubHost: "github.com"} err = os.MkdirAll(filepath.Join(root, "github.com", "atom", "atom", ".git"), 0777) require.NoError(t, err) @@ -237,12 +272,10 @@ func TestQuery(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(path2, ".git"), 0755)) path3 := filepath.Join(root1, "github.com", "kyoh86", "foo") require.NoError(t, os.MkdirAll(filepath.Join(path3, ".git"), 0755)) - path4 := filepath.Join(root1, "example.com", "kyoh86", "gogh") - require.NoError(t, os.MkdirAll(filepath.Join(path4, ".git"), 0755)) path5 := filepath.Join(root2, "github.com", "kyoh86", "gogh") require.NoError(t, os.MkdirAll(filepath.Join(path5, ".git"), 0755)) - ctx := implContext{roots: []string{root1, root2}, gheHosts: []string{"example.com"}} + ctx := context.MockContext{MRoot: []string{root1, root2}, MGitHubHost: "github.com"} assert.NoError(t, Query(&ctx, "never found", Walk, func(*Project) error { t.Fatal("should not be called but...") @@ -253,7 +286,6 @@ func TestQuery(t *testing.T) { expect := map[string]struct{}{ path1: {}, path2: {}, - path4: {}, path5: {}, } assert.NoError(t, Query(&ctx, "gogh", Walk, func(p *Project) error { @@ -267,7 +299,6 @@ func TestQuery(t *testing.T) { expect := map[string]struct{}{ path1: {}, path2: {}, - path4: {}, path5: {}, } assert.NoError(t, Query(&ctx, "gog", Walk, func(p *Project) error { @@ -280,7 +311,6 @@ func TestQuery(t *testing.T) { t.Run("OwnerAndName", func(t *testing.T) { expect := map[string]struct{}{ path1: {}, - path4: {}, path5: {}, } assert.NoError(t, Query(&ctx, "kyoh86/gogh", Walk, func(p *Project) error { @@ -293,7 +323,6 @@ func TestQuery(t *testing.T) { t.Run("PartialOwnerAndName", func(t *testing.T) { expect := map[string]struct{}{ path1: {}, - path4: {}, path5: {}, } assert.NoError(t, Query(&ctx, "yoh86/gog", Walk, func(p *Project) error { @@ -331,7 +360,6 @@ func TestQuery(t *testing.T) { expect := map[string]struct{}{ path1: {}, path2: {}, - path4: {}, } assert.NoError(t, Query(&ctx, "gogh", WalkInPrimary, func(p *Project) error { assert.Contains(t, expect, p.FullPath) diff --git a/gogh/repo.go b/gogh/repo.go index 467bdf7e..78c0ebdf 100644 --- a/gogh/repo.go +++ b/gogh/repo.go @@ -37,19 +37,14 @@ func ParseRepo(rawRepo string) (*Repo, error) { // CheckRepoHost that repo is in supported host func CheckRepoHost(ctx Context, repo *Repo) error { - return ValidateHost(ctx, repo.Host(ctx)) + return SupportedHost(ctx, repo.Host(ctx)) } -// ValidateHost that repo is in supported host -func ValidateHost(ctx Context, host string) error { - if host == DefaultHost { +// SupportedHost that repo is in supported host +func SupportedHost(ctx Context, host string) error { + if host == ctx.GitHubHost() { return nil } - for _, h := range ctx.GHEHosts() { - if h == host { - return nil - } - } return fmt.Errorf("not supported host: %q", host) } @@ -59,30 +54,6 @@ func ValidateHost(ctx Context, host string) error { var hasSchemePattern = regexp.MustCompile("^[^:]+://") var scpLikeURLPattern = regexp.MustCompile("^([^@]+@)?([^:]+):/?(.+)$") -var invalidNameRegexp = regexp.MustCompile(`[^\w\-\.]`) - -func ValidateName(name string) error { - if name == "." || name == ".." { - return errors.New("'.' or '..' is reserved name") - } - if name == "" { - return errors.New("empty project name") - } - if invalidNameRegexp.MatchString(name) { - return errors.New("project name may only contain alphanumeric characters, dots or hyphens") - } - return nil -} - -var validOwnerRegexp = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`) - -func ValidateOwner(owner string) error { - if !validOwnerRegexp.MatchString(owner) { - return errors.New("owner name may only contain alphanumeric characters or single hyphens, and cannot begin or end with a hyphen") - } - return nil -} - // Set text as Repo func (r *Repo) Set(rawRepo string) error { raw := rawRepo @@ -108,7 +79,7 @@ func (r *Repo) Set(rawRepo string) error { path = strings.Trim(url.Path, "/") } else { r.scheme = "https" - r.host = DefaultHost + r.host = "" // use default value r.user = nil path = url.Path } @@ -134,29 +105,29 @@ func (r *Repo) Set(rawRepo string) error { return err } default: - return errors.New("repository remote has too many slashes") + return errors.New("repository name has too many slashes") } r.raw = raw return nil } -// DefaultHost is the default host of the GitHub -const DefaultHost = "github.com" - // Scheme returns scheme of the repository func (r *Repo) Scheme(_ Context) string { return r.scheme } // Host returns host of the repository -func (r *Repo) Host(_ Context) string { +func (r *Repo) Host(ctx Context) string { + if r.host == "" { + return ctx.GitHubHost() + } return r.host } // Owner returns a user name of an owner of the repository func (r *Repo) Owner(ctx Context) string { if r.owner == "" { - return ctx.UserName() + return ctx.GitHubUser() } return r.owner } diff --git a/gogh/repo_test.go b/gogh/repo_test.go index 97348440..b0dc5cfa 100644 --- a/gogh/repo_test.go +++ b/gogh/repo_test.go @@ -3,13 +3,14 @@ package gogh import ( "testing" + "github.com/kyoh86/gogh/internal/context" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRepo(t *testing.T) { t.Run("full HTTPS URL", func(t *testing.T) { - ctx := &implContext{} + ctx := &context.MockContext{} spec, err := ParseRepo("https://github.com/kyoh86/pusheen-explorer") require.NoError(t, err) assert.Equal(t, "kyoh86/pusheen-explorer", spec.FullName(ctx)) @@ -19,7 +20,7 @@ func TestRepo(t *testing.T) { }) t.Run("scp like URL 1", func(t *testing.T) { - ctx := &implContext{} + ctx := &context.MockContext{} spec, err := ParseRepo("git@github.com:kyoh86/pusheen-explorer.git") require.NoError(t, err) assert.Equal(t, "kyoh86/pusheen-explorer", spec.FullName(ctx)) @@ -28,7 +29,7 @@ func TestRepo(t *testing.T) { }) t.Run("scp like URL 2", func(t *testing.T) { - ctx := &implContext{} + ctx := &context.MockContext{} spec, err := ParseRepo("git@github.com:/kyoh86/pusheen-explorer.git") require.NoError(t, err) assert.Equal(t, "kyoh86/pusheen-explorer", spec.FullName(ctx)) @@ -37,7 +38,7 @@ func TestRepo(t *testing.T) { }) t.Run("scp like URL 3", func(t *testing.T) { - ctx := &implContext{} + ctx := &context.MockContext{} spec, err := ParseRepo("github.com:kyoh86/pusheen-explorer.git") require.NoError(t, err) assert.Equal(t, "kyoh86/pusheen-explorer", spec.FullName(ctx)) @@ -46,7 +47,7 @@ func TestRepo(t *testing.T) { }) t.Run("owner/name spec", func(t *testing.T) { - ctx := &implContext{} + ctx := &context.MockContext{MGitHubHost: "github.com"} spec, err := ParseRepo("kyoh86/gogh") require.NoError(t, err) assert.Equal(t, "kyoh86/gogh", spec.FullName(ctx)) @@ -55,7 +56,7 @@ func TestRepo(t *testing.T) { }) t.Run("name only spec", func(t *testing.T) { - ctx := &implContext{userName: "kyoh86"} + ctx := &context.MockContext{MGitHubUser: "kyoh86", MGitHubHost: "github.com"} spec, err := ParseRepo("gogh") require.NoError(t, err) assert.Equal(t, "kyoh86/gogh", spec.FullName(ctx)) @@ -146,29 +147,15 @@ func TestRepos(t *testing.T) { func TestCheckRepoHost(t *testing.T) { t.Run("valid GitHub URL", func(t *testing.T) { - ctx := implContext{} + ctx := context.MockContext{MGitHubHost: "github.com"} assert.NoError(t, CheckRepoHost(&ctx, parseURL(t, "https://github.com/kyoh86/gogh"))) }) - t.Run("valid GHE URL", func(t *testing.T) { - ctx := implContext{ - gheHosts: []string{"example.com"}, - } - assert.NoError(t, CheckRepoHost(&ctx, parseURL(t, "https://example.com/kyoh86/gogh"))) - }) t.Run("valid GitHub URL with trailing slashes", func(t *testing.T) { - ctx := implContext{} + ctx := context.MockContext{MGitHubHost: "github.com"} assert.NoError(t, CheckRepoHost(&ctx, parseURL(t, "https://github.com/kyoh86/gogh/"))) }) - t.Run("valid GHE URL with trailing slashes", func(t *testing.T) { - ctx := implContext{ - gheHosts: []string{"example.com"}, - } - assert.NoError(t, CheckRepoHost(&ctx, parseURL(t, "https://example.com/kyoh86/gogh/"))) - }) t.Run("not supported host URL", func(t *testing.T) { - ctx := implContext{ - gheHosts: []string{"example.com"}, - } + ctx := context.MockContext{MGitHubHost: "github.com"} assert.EqualError(t, CheckRepoHost(&ctx, parseURL(t, "https://kyoh86.work/kyoh86/gogh")), `not supported host: "kyoh86.work"`) }) } diff --git a/gogh/util.go b/gogh/util.go index 9599cfc6..72f551ad 100644 --- a/gogh/util.go +++ b/gogh/util.go @@ -1,14 +1 @@ package gogh - -func unique(items []string) (uniq []string) { - dups := map[string]struct{}{} - - for _, item := range items { - if _, ok := dups[item]; ok { - continue - } - dups[item] = struct{}{} - uniq = append(uniq, item) - } - return -} diff --git a/gogh/validation.go b/gogh/validation.go new file mode 100644 index 00000000..7ffc6558 --- /dev/null +++ b/gogh/validation.go @@ -0,0 +1,80 @@ +package gogh + +import ( + "errors" + "os" + "path/filepath" + "regexp" + + "github.com/comail/colog" +) + +var invalidNameRegexp = regexp.MustCompile(`[^\w\-\.]`) + +func ValidateName(name string) error { + if name == "." || name == ".." { + return errors.New("'.' or '..' is reserved name") + } + if name == "" { + return errors.New("empty project name") + } + if invalidNameRegexp.MatchString(name) { + return errors.New("project name may only contain alphanumeric characters, dots or hyphens") + } + return nil +} + +var validOwnerRegexp = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`) + +func ValidateOwner(owner string) error { + if !validOwnerRegexp.MatchString(owner) { + return errors.New("owner name may only contain alphanumeric characters or single hyphens, and cannot begin or end with a hyphen") + } + return nil +} + +func ValidateRoot(root string) (string, error) { + path := filepath.Clean(root) + _, err := os.Stat(path) + switch { + case err == nil: + return filepath.EvalSymlinks(path) + case os.IsNotExist(err): + return path, nil + default: + return "", err + } +} + +func ValidateRoots(roots []string) error { + for i, v := range roots { + r, err := ValidateRoot(v) + if err != nil { + return err + } + roots[i] = r + } + if len(roots) == 0 { + return errors.New("no root") + } + + return nil +} + +func ValidateLogLevel(level string) error { + _, err := colog.ParseLevel(level) + return err +} + +func ValidateContext(ctx Context) error { + if err := ValidateRoots(ctx.Root()); err != nil { + return err + } + if err := ValidateOwner(ctx.GitHubUser()); err != nil { + return err + } + if err := ValidateLogLevel(ctx.LogLevel()); err != nil { + return err + } + return nil +} diff --git a/gogh/validation_test.go b/gogh/validation_test.go new file mode 100644 index 00000000..f15c5b82 --- /dev/null +++ b/gogh/validation_test.go @@ -0,0 +1,71 @@ +package gogh + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/kyoh86/gogh/internal/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateName(t *testing.T) { + assert.EqualError(t, ValidateName(""), "empty project name", "empty project name is invalid") + assert.EqualError(t, ValidateName("."), "'.' or '..' is reserved name", "'dot' conflicts with 'current directory'") + assert.EqualError(t, ValidateName(".."), "'.' or '..' is reserved name", "'dot' conflicts with 'parent directory'") + assert.EqualError(t, ValidateName("kyoh86/gogh"), "project name may only contain alphanumeric characters, dots or hyphens", "slashes must not be contained in project name") + assert.NoError(t, ValidateName("----..--.."), "hyphens and dots are usable in project name") +} + +func TestValidateOwner(t *testing.T) { + expect := "owner name may only contain alphanumeric characters or single hyphens, and cannot begin or end with a hyphen" + assert.EqualError(t, ValidateOwner(""), expect, "fail when empty owner is given") + assert.EqualError(t, ValidateOwner("kyoh_86"), expect, "fail when owner name contains invalid charactor") + assert.EqualError(t, ValidateOwner("-kyoh86"), expect, "fail when owner name starts with hyphen") + assert.EqualError(t, ValidateOwner("kyoh86-"), expect, "fail when owner name ends with hyphen") + assert.NoError(t, ValidateOwner("kyoh86"), "success") +} + +func TestValidateRoots(t *testing.T) { + tmp, err := ioutil.TempDir(os.TempDir(), "gogh-test") + require.NoError(t, err) + assert.EqualError(t, ValidateRoots([]string{}), "no root", "fail when no path in root") + assert.NoError(t, ValidateRoots([]string{"/path/to/not/existing", tmp})) + assert.Error(t, ValidateRoots([]string{"\x00", tmp})) +} + +func TestValidateContext(t *testing.T) { + t.Run("invalid root", func(t *testing.T) { + ctx := &context.MockContext{ + MRoot: []string{"/\x00"}, + MLogLevel: "warn", + MGitHubUser: "kyoh86", + } + assert.Error(t, ValidateContext(ctx)) + }) + t.Run("invalid owner", func(t *testing.T) { + ctx := &context.MockContext{ + MRoot: []string{"/path/to/not/existing"}, + MLogLevel: "warn", + MGitHubUser: "", + } + assert.Error(t, ValidateContext(ctx)) + }) + t.Run("invalid loglevel", func(t *testing.T) { + ctx := &context.MockContext{ + MRoot: []string{"/path/to/not/existing"}, + MLogLevel: "invalid", + MGitHubUser: "kyoh86", + } + assert.Error(t, ValidateContext(ctx)) + }) + t.Run("valid context", func(t *testing.T) { + ctx := &context.MockContext{ + MRoot: []string{"/path/to/not/existing"}, + MLogLevel: "warn", + MGitHubUser: "kyoh86", + } + assert.NoError(t, ValidateContext(ctx)) + }) +} diff --git a/internal/context/mock.go b/internal/context/mock.go new file mode 100644 index 00000000..ed5f9867 --- /dev/null +++ b/internal/context/mock.go @@ -0,0 +1,83 @@ +package context + +import ( + "bytes" + "context" + "io" + "io/ioutil" +) + +type MockContext struct { + context.Context + MStdin io.Reader + MStdout io.Writer + MStderr io.Writer + MGitHubUser string + MGitHubToken string + MGitHubHost string + MLogLevel string + MLogFlags int + MLogDate bool + MLogTime bool + MLogMicroSeconds bool + MLogLongFile bool + MLogShortFile bool + MLogUTC bool + MRoot []string +} + +func (c *MockContext) Stdin() io.Reader { + if r := c.MStdin; r != nil { + return r + } + return &bytes.Buffer{} +} + +func (c *MockContext) Stdout() io.Writer { + if w := c.MStdout; w != nil { + return w + } + return ioutil.Discard +} + +func (c *MockContext) Stderr() io.Writer { + if w := c.MStderr; w != nil { + return w + } + return ioutil.Discard +} + +func (c *MockContext) GitHubUser() string { + return c.MGitHubUser +} + +func (c *MockContext) GitHubToken() string { + return c.MGitHubToken +} + +func (c *MockContext) GitHubHost() string { + return c.MGitHubHost +} + +func (c *MockContext) LogLevel() string { + return c.MLogLevel +} + +func (c *MockContext) LogFlags() int { + return c.MLogFlags +} + +func (c *MockContext) LogDate() bool { return c.MLogDate } +func (c *MockContext) LogTime() bool { return c.MLogTime } +func (c *MockContext) LogMicroSeconds() bool { return c.MLogMicroSeconds } +func (c *MockContext) LogLongFile() bool { return c.MLogLongFile } +func (c *MockContext) LogShortFile() bool { return c.MLogShortFile } +func (c *MockContext) LogUTC() bool { return c.MLogUTC } + +func (c *MockContext) Root() []string { + return c.MRoot +} + +func (c *MockContext) PrimaryRoot() string { + return c.MRoot[0] +} diff --git a/internal/mainutil/mainutil.go b/internal/mainutil/mainutil.go new file mode 100644 index 00000000..0519b00b --- /dev/null +++ b/internal/mainutil/mainutil.go @@ -0,0 +1,103 @@ +package mainutil + +import ( + "os" + "path/filepath" + + "github.com/alecthomas/kingpin" + "github.com/comail/colog" + "github.com/kyoh86/gogh/config" + "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/xdg" +) + +func setConfigFlag(cmd *kingpin.CmdClause, configFile *string) { + cmd.Flag("config", "configuration file"). + Default(filepath.Join(xdg.ConfigHome(), "gogh", "config.yaml")). + Envar("GOGH_CONFIG"). + StringVar(configFile) +} + +func initLog(ctx gogh.Context) error { + lvl, err := colog.ParseLevel(ctx.LogLevel()) + if err != nil { + return err + } + colog.Register() + colog.SetOutput(ctx.Stderr()) + colog.SetFlags(ctx.LogFlags()) + colog.SetMinLevel(lvl) + colog.SetDefaultLevel(colog.LError) + return nil +} + +func currentConfig(configFile string) (*config.Config, *config.Config, error) { + var fileCfg *config.Config + file, err := os.Open(configFile) + switch { + case err == nil: + defer file.Close() + fileCfg, err = config.LoadConfig(file) + if err != nil { + return nil, nil, err + } + case os.IsNotExist(err): + fileCfg = &config.Config{} + default: + return nil, nil, err + } + envarConfig, err := config.GetEnvarConfig() + if err != nil { + return nil, nil, err + } + cfg := config.MergeConfig(config.DefaultConfig(), fileCfg, envarConfig) + if err := gogh.ValidateContext(cfg); err != nil { + return nil, nil, err + } + return fileCfg, cfg, nil +} + +func WrapCommand(cmd *kingpin.CmdClause, f func(gogh.Context) error) (string, func() error) { + var configFile string + setConfigFlag(cmd, &configFile) + return cmd.FullCommand(), func() error { + _, cfg, err := currentConfig(configFile) + if err != nil { + return err + } + + if err := initLog(cfg); err != nil { + return err + } + return f(cfg) + } +} + +func WrapConfigurableCommand(cmd *kingpin.CmdClause, f func(*config.Config) error) (string, func() error) { + var configFile string + setConfigFlag(cmd, &configFile) + return cmd.FullCommand(), func() error { + fileCfg, cfg, err := currentConfig(configFile) + if err != nil { + return err + } + + if err := initLog(cfg); err != nil { + return err + } + + if err = f(fileCfg); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(configFile), 0744); err != nil { + return err + } + file, err := os.OpenFile(configFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer file.Close() + return config.SaveConfig(file, fileCfg) + } +} diff --git a/internal/testutil/writer.go b/internal/testutil/writer.go new file mode 100644 index 00000000..c352a174 --- /dev/null +++ b/internal/testutil/writer.go @@ -0,0 +1,20 @@ +package testutil + +import ( + "errors" + "io" +) + +type ErrorWriter struct { + Error error +} + +var ( + DefaultErrorWriter io.Writer = &ErrorWriter{ + Error: errors.New("error writer"), + } +) + +func (w *ErrorWriter) Write(b []byte) (int, error) { + return 0, w.Error +} diff --git a/main.go b/main.go index 45c4768b..2184d132 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,16 @@ package main import ( - "context" "fmt" "log" "net/url" "os" "github.com/alecthomas/kingpin" - "github.com/comail/colog" "github.com/kyoh86/gogh/command" - "github.com/kyoh86/gogh/command/remote" + "github.com/kyoh86/gogh/config" "github.com/kyoh86/gogh/gogh" + "github.com/kyoh86/gogh/internal/mainutil" ) var ( @@ -22,9 +21,15 @@ var ( func main() { app := kingpin.New("gogh", "GO GitHub project manager").Version(fmt.Sprintf("%s-%s (%s)", version, commit, date)).Author("kyoh86") + app.Command("config", "Get and set options") cmds := map[string]func() error{} for _, f := range []func(*kingpin.Application) (string, func() error){ + configGetAll, + configGet, + configPut, + configUnset, + get, bulk, pipe, @@ -47,22 +52,50 @@ func main() { } } -func wrapContext(f func(gogh.Context) error) func() error { - return func() error { - ctx, err := gogh.CurrentContext(context.Background()) - if err != nil { - return err - } - lvl, err := colog.ParseLevel(ctx.LogLevel()) - if err != nil { - return err - } - colog.Register() - colog.SetOutput(ctx.Stderr()) - colog.SetMinLevel(lvl) - colog.SetDefaultLevel(colog.LError) - return f(ctx) - } +func configGetAll(app *kingpin.Application) (string, func() error) { + cmd := app.GetCommand("config").Command("get-all", "get all options") + + return mainutil.WrapConfigurableCommand(cmd, func(cfg *config.Config) error { + return command.ConfigGetAll(cfg) + }) +} + +func configGet(app *kingpin.Application) (string, func() error) { + var ( + name string + ) + cmd := app.GetCommand("config").Command("get", "get an option") + cmd.Arg("name", "option name").Required().StringVar(&name) + + return mainutil.WrapConfigurableCommand(cmd, func(cfg *config.Config) error { + return command.ConfigGet(cfg, name) + }) +} + +func configPut(app *kingpin.Application) (string, func() error) { + var ( + name string + value string + ) + cmd := app.GetCommand("config").Command("put", "put an option") + cmd.Arg("name", "option name").Required().StringVar(&name) + cmd.Arg("value", "option value").Required().StringVar(&value) + + return mainutil.WrapConfigurableCommand(cmd, func(cfg *config.Config) error { + return command.ConfigPut(cfg, name, value) + }) +} + +func configUnset(app *kingpin.Application) (string, func() error) { + var ( + name string + ) + cmd := app.GetCommand("config").Command("unset", "unset an option") + cmd.Arg("name", "option name").Required().StringVar(&name) + + return mainutil.WrapConfigurableCommand(cmd, func(cfg *config.Config) error { + return command.ConfigUnset(cfg, name) + }) } func get(app *kingpin.Application) (string, func() error) { @@ -78,7 +111,7 @@ func get(app *kingpin.Application) (string, func() error) { cmd.Flag("shallow", "Do a shallow clone").BoolVar(&shallow) cmd.Arg("repositories", "Target repositories ( | / | )").Required().SetValue(&repoNames) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.GetAll(ctx, update, withSSH, shallow, repoNames) }) } @@ -94,7 +127,7 @@ func bulk(app *kingpin.Application) (string, func() error) { cmd.Flag("ssh", "Clone with SSH").BoolVar(&withSSH) cmd.Flag("shallow", "Do a shallow clone").BoolVar(&shallow) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.Bulk(ctx, update, withSSH, shallow) }) } @@ -114,7 +147,7 @@ func pipe(app *kingpin.Application) (string, func() error) { cmd.Arg("command", "Subcommand calling to get import paths").StringVar(&srcCmd) cmd.Arg("command-args", "Arguments that will be passed to subcommand").StringsVar(&srcCmdArgs) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.Pipe(ctx, update, withSSH, shallow, srcCmd, srcCmdArgs) }) } @@ -138,7 +171,7 @@ func fork(app *kingpin.Application) (string, func() error) { cmd.Flag("org", "Fork the repository within this organization").PlaceHolder("ORGANIZATION").StringVar(&organization) cmd.Arg("repository", "Target repository ( | / | )").Required().SetValue(&repo) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.Fork(ctx, update, withSSH, shallow, noRemote, remoteName, organization, &repo) }) } @@ -168,7 +201,7 @@ func create(app *kingpin.Application) (string, func() error) { cmd.Flag("shared", "Specify that the Git repository is to be shared amongst several users.").SetValue(&shared) cmd.Arg("repository", "Target repository ( | / | )").Required().SetValue(&repo) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.New(ctx, private, description, homepage, browse, clip, bare, template, separateGitDir, shared, &repo) }) } @@ -176,14 +209,16 @@ func create(app *kingpin.Application) (string, func() error) { func where(app *kingpin.Application) (string, func() error) { var ( primary bool + exact bool query string ) cmd := app.Command("where", "Where is a local project") cmd.Flag("primary", "Only in primary root directory").Short('p').BoolVar(&primary) + cmd.Flag("exact", "Specifies name of the project in query").Short('e').BoolVar(&exact) cmd.Arg("query", "Project name query").StringVar(&query) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { - return command.Where(ctx, primary, query) + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { + return command.Where(ctx, primary, exact, query) }) } @@ -198,7 +233,7 @@ func list(app *kingpin.Application) (string, func() error) { cmd.Flag("primary", "Only in primary root directory").Short('p').BoolVar(&primary) cmd.Arg("query", "Project name query").StringVar(&query) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.List(ctx, gogh.ProjectListFormat(format), primary, query) }) } @@ -212,18 +247,22 @@ func dump(app *kingpin.Application) (string, func() error) { cmd.Flag("primary", "Only in primary root directory").Short('p').BoolVar(&primary) cmd.Arg("query", "Project name query").StringVar(&query) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.List(ctx, gogh.ProjectListFormatURL, primary, query) }) } func find(app *kingpin.Application) (string, func() error) { - var repo gogh.Repo - cmd := app.Command("find", "Find a path of a project").Alias("search-project") - cmd.Arg("repository", "Target repository ( | / | )").Required().SetValue(&repo) + var ( + primary bool + query string + ) + cmd := app.Command("find", "Find a path of a project. This is shorthand of `gogh where --exact`") + cmd.Flag("primary", "Only in primary root directory").Short('p').BoolVar(&primary) + cmd.Arg("query", "Project name query").StringVar(&query) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { - return command.Find(ctx, &repo) + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { + return command.Where(ctx, primary, true, query) }) } @@ -232,7 +271,7 @@ func root(app *kingpin.Application) (string, func() error) { cmd := app.Command("root", "Show repositories' root") cmd.Flag("all", "Show all roots").Envar("GOGH_FLAG_ROOT_ALL").BoolVar(&all) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.Root(ctx, all) }) } @@ -246,7 +285,7 @@ func setup(app *kingpin.Application) (string, func() error) { cmd.Flag("cd-function-name", "Name of the function to define").Default("gogogh").Hidden().StringVar(&cdFuncName) cmd.Flag("shell", "Target shell path").Envar("SHELL").Hidden().StringVar(&shell) - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { return command.Setup(ctx, cdFuncName, shell) }) } @@ -270,7 +309,7 @@ func repos(app *kingpin.Application) (string, func() error) { cmd.Flag("sort", "Sort repositories by").Default("full_name").EnumVar(&sort, "created", "updated", "pushed", "full_name") cmd.Flag("direction", "Sort direction").Default("default").EnumVar(&direction, "asc", "desc", "default") - return cmd.FullCommand(), wrapContext(func(ctx gogh.Context) error { - return remote.Repo(ctx, user, own, collaborate, member, visibility, sort, direction) + return mainutil.WrapCommand(cmd, func(ctx gogh.Context) error { + return command.Repos(ctx, user, own, collaborate, member, visibility, sort, direction) }) } diff --git a/gogh/remote/client.go b/remote/client.go similarity index 91% rename from gogh/remote/client.go rename to remote/client.go index 4f2eded9..f74c240c 100644 --- a/gogh/remote/client.go +++ b/remote/client.go @@ -11,7 +11,7 @@ import ( // NewClient builds GitHub Client with GitHub API token that is configured. func NewClient(ctx gogh.Context) (*github.Client, error) { - if host := ctx.GitHubHost(); host != "" && host != gogh.DefaultHost { + if host := ctx.GitHubHost(); host != "" && host != "github.com" { url := fmt.Sprintf("https://%s/api/v3", host) return github.NewEnterpriseClient(url, url, oauth2Client(ctx)) } diff --git a/gogh/remote/repo.go b/remote/repos.go similarity index 91% rename from gogh/remote/repo.go rename to remote/repos.go index f5c89f76..1277d24b 100644 --- a/gogh/remote/repo.go +++ b/remote/repos.go @@ -7,7 +7,7 @@ import ( "github.com/kyoh86/gogh/gogh" ) -// Repo will get a list of repositories for a user. +// Repos will get a list of repositories for a user. // Parameters: // * user: Who has the repositories. Empty means the "me" (authenticated user, or GOGH_GITHUB_USER). // * own: Include repositories that are owned by the user @@ -18,7 +18,7 @@ import ( // * direction: Can be one of asc or desc default. Default means asc when using full_name, otherwise desc // Returns: // List of the url for repoisitories -func Repo(ctx gogh.Context, user string, own, collaborate, member bool, visibility, sort, direction string) ([]string, error) { +func Repos(ctx gogh.Context, user string, own, collaborate, member bool, visibility, sort, direction string) ([]string, error) { client, err := NewClient(ctx) if err != nil { return nil, err @@ -44,7 +44,7 @@ func Repo(ctx gogh.Context, user string, own, collaborate, member bool, visibili } // If the context has no authentication token, specifies context user name for "me". if user == "" && !authenticated(ctx) { - user = ctx.UserName() + user = ctx.GitHubUser() } opts := &github.RepositoryListOptions{