diff options
author | Michael Muré <batolettre@gmail.com> | 2023-01-17 20:14:10 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-17 20:14:10 +0100 |
commit | e689cc506775ec1daccaae9ec35c7a28b48b2f05 (patch) | |
tree | 7d86adb1fb9d290f5acfa3b36f606f4e24f01fa7 /commands | |
parent | 2a78fd9c94960d86264e39d37e5a3e25b0a8358d (diff) | |
parent | f011452a2d7ed26d522896a1dab090d7ede05cf1 (diff) | |
download | git-bug-e689cc506775ec1daccaae9ec35c7a28b48b2f05.tar.gz |
Merge pull request #994 from MichaelMure/feat/detect-stdin-stdout-mode
feat: detect `os.Stdin`/`os.Stdout` redirection/pipe mode
Diffstat (limited to 'commands')
-rw-r--r-- | commands/execenv/env.go | 218 | ||||
-rw-r--r-- | commands/execenv/env_test.go | 21 | ||||
-rw-r--r-- | commands/execenv/env_testing.go | 39 | ||||
-rw-r--r-- | commands/execenv/loading.go | 169 |
4 files changed, 281 insertions, 166 deletions
diff --git a/commands/execenv/env.go b/commands/execenv/env.go index d2d1c301..46de8401 100644 --- a/commands/execenv/env.go +++ b/commands/execenv/env.go @@ -6,14 +6,10 @@ import ( "io" "os" - "github.com/spf13/cobra" - "github.com/vbauerster/mpb/v8" - "github.com/vbauerster/mpb/v8/decor" + "github.com/mattn/go-isatty" "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/entities/identity" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/interrupt" ) const RootCommandName = "git-bug" @@ -24,6 +20,7 @@ const gitBugNamespace = "git-bug" type Env struct { Repo repository.ClockedRepo Backend *cache.RepoCache + In In Out Out Err Out } @@ -31,18 +28,40 @@ type Env struct { func NewEnv() *Env { return &Env{ Repo: nil, + In: in{Reader: os.Stdin}, Out: out{Writer: os.Stdout}, Err: out{Writer: os.Stderr}, } } +type In interface { + io.Reader + + // IsTerminal tells if the input is a user terminal (rather than a buffer, + // a pipe ...), which tells if we can use interactive features. + IsTerminal() bool + + // ForceIsTerminal allow to force the returned value of IsTerminal + // This only works in test scenario. + ForceIsTerminal(value bool) +} + type Out interface { io.Writer + Printf(format string, a ...interface{}) Print(a ...interface{}) Println(a ...interface{}) PrintJSON(v interface{}) error + // IsTerminal tells if the output is a user terminal (rather than a buffer, + // a pipe ...), which tells if we can use colors and other interactive features. + IsTerminal() bool + + // Raw return the underlying io.Writer, or itself if not. + // This is useful if something need to access the raw file descriptor. + Raw() io.Writer + // String returns what have been written in the output before, as a string. // This only works in test scenario. String() string @@ -52,10 +71,24 @@ type Out interface { // Reset clear what has been recorded as written in the output before. // This only works in test scenario. Reset() + // ForceIsTerminal allow to force the returned value of IsTerminal + // This only works in test scenario. + ForceIsTerminal(value bool) +} - // Raw return the underlying io.Writer, or itself if not. - // This is useful if something need to access the raw file descriptor. - Raw() io.Writer +type in struct { + io.Reader +} + +func (i in) IsTerminal() bool { + if f, ok := i.Reader.(*os.File); ok { + return isTerminal(f) + } + return false +} + +func (i in) ForceIsTerminal(_ bool) { + panic("only work with a test env") } type out struct { @@ -83,172 +116,33 @@ func (o out) PrintJSON(v interface{}) error { return nil } -func (o out) String() string { - panic("only work with a test env") -} - -func (o out) Bytes() []byte { - panic("only work with a test env") -} - -func (o out) Reset() { - panic("only work with a test env") +func (o out) IsTerminal() bool { + if f, ok := o.Writer.(*os.File); ok { + return isTerminal(f) + } + return false } func (o out) Raw() io.Writer { return o.Writer } -// LoadRepo is a pre-run function that load the repository for use in a command -func LoadRepo(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("unable to get the current working directory: %q", err) - } - - // Note: we are not loading clocks here because we assume that LoadRepo is only used - // when we don't manipulate entities, or as a child call of LoadBackend which will - // read all clocks anyway. - env.Repo, err = repository.OpenGoGitRepo(cwd, gitBugNamespace, nil) - if err == repository.ErrNotARepo { - return fmt.Errorf("%s must be run from within a git Repo", RootCommandName) - } - if err != nil { - return err - } - - return nil - } -} - -// LoadRepoEnsureUser is the same as LoadRepo, but also ensure that the user has configured -// an identity. Use this pre-run function when an error after using the configured user won't -// do. -func LoadRepoEnsureUser(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := LoadRepo(env)(cmd, args) - if err != nil { - return err - } - - _, err = identity.GetUserIdentity(env.Repo) - if err != nil { - return err - } - - return nil - } +func (o out) String() string { + panic("only work with a test env") } -// LoadBackend is a pre-run function that load the repository and the Backend for use in a command -// When using this function you also need to use CloseBackend as a post-run -func LoadBackend(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := LoadRepo(env)(cmd, args) - if err != nil { - return err - } - - var events chan cache.BuildEvent - env.Backend, events = cache.NewRepoCache(env.Repo) - - err = CacheBuildProgressBar(env, events) - if err != nil { - return err - } - - cleaner := func(env *Env) interrupt.CleanerFunc { - return func() error { - if env.Backend != nil { - err := env.Backend.Close() - env.Backend = nil - return err - } - return nil - } - } - - // Cleanup properly on interrupt - interrupt.RegisterCleaner(cleaner(env)) - return nil - } +func (o out) Bytes() []byte { + panic("only work with a test env") } -// LoadBackendEnsureUser is the same as LoadBackend, but also ensure that the user has configured -// an identity. Use this pre-run function when an error after using the configured user won't -// do. -func LoadBackendEnsureUser(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := LoadBackend(env)(cmd, args) - if err != nil { - return err - } - - _, err = identity.GetUserIdentity(env.Repo) - if err != nil { - return err - } - - return nil - } +func (o out) Reset() { + panic("only work with a test env") } -// CloseBackend is a wrapper for a RunE function that will close the Backend properly -// if it has been opened. -// This wrapper style is necessary because a Cobra PostE function does not run if RunE return an error. -func CloseBackend(env *Env, runE func(cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - errRun := runE(cmd, args) - - if env.Backend == nil { - return nil - } - err := env.Backend.Close() - env.Backend = nil - - // prioritize the RunE error - if errRun != nil { - return errRun - } - return err - } +func (o out) ForceIsTerminal(_ bool) { + panic("only work with a test env") } -func CacheBuildProgressBar(env *Env, events chan cache.BuildEvent) error { - var progress *mpb.Progress - var bars = make(map[string]*mpb.Bar) - - for event := range events { - if event.Err != nil { - return event.Err - } - - if progress == nil { - progress = mpb.New(mpb.WithOutput(env.Err.Raw())) - } - - switch event.Event { - case cache.BuildEventCacheIsBuilt: - env.Err.Println("Building cache... ") - case cache.BuildEventStarted: - bars[event.Typename] = progress.AddBar(-1, - mpb.BarRemoveOnComplete(), - mpb.PrependDecorators( - decor.Name(event.Typename, decor.WCSyncSpace), - decor.CountersNoUnit("%d / %d", decor.WCSyncSpace), - ), - mpb.AppendDecorators(decor.Percentage(decor.WCSyncSpace)), - ) - case cache.BuildEventProgress: - bars[event.Typename].SetTotal(event.Total, false) - bars[event.Typename].SetCurrent(event.Progress) - } - } - - if progress != nil { - progress.Shutdown() - } - - return nil +func isTerminal(file *os.File) bool { + return isatty.IsTerminal(file.Fd()) || isatty.IsCygwinTerminal(file.Fd()) } diff --git a/commands/execenv/env_test.go b/commands/execenv/env_test.go new file mode 100644 index 00000000..3fc6e581 --- /dev/null +++ b/commands/execenv/env_test.go @@ -0,0 +1,21 @@ +package execenv + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsTerminal(t *testing.T) { + // easy way to get a reader and a writer + r, w, err := os.Pipe() + require.NoError(t, err) + + require.False(t, isTerminal(r)) + require.False(t, isTerminal(w)) + + // golang's testing framework replaces os.Stdin and os.Stdout, so the following doesn't work here + // require.True(t, isTerminal(os.Stdin)) + // require.True(t, isTerminal(os.Stdout)) +} diff --git a/commands/execenv/env_testing.go b/commands/execenv/env_testing.go index 6eb9c69d..03fe0430 100644 --- a/commands/execenv/env_testing.go +++ b/commands/execenv/env_testing.go @@ -13,10 +13,26 @@ import ( "github.com/MichaelMure/git-bug/repository" ) +var _ In = &TestIn{} + +type TestIn struct { + *bytes.Buffer + forceIsTerminal bool +} + +func (t *TestIn) ForceIsTerminal(value bool) { + t.forceIsTerminal = value +} + +func (t *TestIn) IsTerminal() bool { + return t.forceIsTerminal +} + var _ Out = &TestOut{} type TestOut struct { *bytes.Buffer + forceIsTerminal bool } func (te *TestOut) Printf(format string, a ...interface{}) { @@ -40,17 +56,31 @@ func (te *TestOut) PrintJSON(v interface{}) error { return nil } +func (te *TestOut) IsTerminal() bool { + return te.forceIsTerminal +} + func (te *TestOut) Raw() io.Writer { return te.Buffer } +func (te *TestOut) ForceIsTerminal(value bool) { + te.forceIsTerminal = value +} + func NewTestEnv(t *testing.T) *Env { t.Helper() + return newTestEnv(t, false) +} + +func NewTestEnvTerminal(t *testing.T) *Env { + t.Helper() + return newTestEnv(t, true) +} +func newTestEnv(t *testing.T, isTerminal bool) *Env { repo := repository.CreateGoGitTestRepo(t, false) - buf := new(bytes.Buffer) - backend, err := cache.NewRepoCacheNoEvents(repo) require.NoError(t, err) @@ -61,7 +91,8 @@ func NewTestEnv(t *testing.T) *Env { return &Env{ Repo: repo, Backend: backend, - Out: &TestOut{buf}, - Err: &TestOut{buf}, + In: &TestIn{Buffer: &bytes.Buffer{}, forceIsTerminal: isTerminal}, + Out: &TestOut{Buffer: &bytes.Buffer{}, forceIsTerminal: isTerminal}, + Err: &TestOut{Buffer: &bytes.Buffer{}, forceIsTerminal: isTerminal}, } } diff --git a/commands/execenv/loading.go b/commands/execenv/loading.go new file mode 100644 index 00000000..2263f700 --- /dev/null +++ b/commands/execenv/loading.go @@ -0,0 +1,169 @@ +package execenv + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +// LoadRepo is a pre-run function that load the repository for use in a command +func LoadRepo(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("unable to get the current working directory: %q", err) + } + + // Note: we are not loading clocks here because we assume that LoadRepo is only used + // when we don't manipulate entities, or as a child call of LoadBackend which will + // read all clocks anyway. + env.Repo, err = repository.OpenGoGitRepo(cwd, gitBugNamespace, nil) + if err == repository.ErrNotARepo { + return fmt.Errorf("%s must be run from within a git Repo", RootCommandName) + } + if err != nil { + return err + } + + return nil + } +} + +// LoadRepoEnsureUser is the same as LoadRepo, but also ensure that the user has configured +// an identity. Use this pre-run function when an error after using the configured user won't +// do. +func LoadRepoEnsureUser(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadRepo(env)(cmd, args) + if err != nil { + return err + } + + _, err = identity.GetUserIdentity(env.Repo) + if err != nil { + return err + } + + return nil + } +} + +// LoadBackend is a pre-run function that load the repository and the Backend for use in a command +// When using this function you also need to use CloseBackend as a post-run +func LoadBackend(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadRepo(env)(cmd, args) + if err != nil { + return err + } + + var events chan cache.BuildEvent + env.Backend, events = cache.NewRepoCache(env.Repo) + + err = CacheBuildProgressBar(env, events) + if err != nil { + return err + } + + cleaner := func(env *Env) interrupt.CleanerFunc { + return func() error { + if env.Backend != nil { + err := env.Backend.Close() + env.Backend = nil + return err + } + return nil + } + } + + // Cleanup properly on interrupt + interrupt.RegisterCleaner(cleaner(env)) + return nil + } +} + +// LoadBackendEnsureUser is the same as LoadBackend, but also ensure that the user has configured +// an identity. Use this pre-run function when an error after using the configured user won't +// do. +func LoadBackendEnsureUser(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadBackend(env)(cmd, args) + if err != nil { + return err + } + + _, err = identity.GetUserIdentity(env.Repo) + if err != nil { + return err + } + + return nil + } +} + +// CloseBackend is a wrapper for a RunE function that will close the Backend properly +// if it has been opened. +// This wrapper style is necessary because a Cobra PostE function does not run if RunE return an error. +func CloseBackend(env *Env, runE func(cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + errRun := runE(cmd, args) + + if env.Backend == nil { + return nil + } + err := env.Backend.Close() + env.Backend = nil + + // prioritize the RunE error + if errRun != nil { + return errRun + } + return err + } +} + +func CacheBuildProgressBar(env *Env, events chan cache.BuildEvent) error { + var progress *mpb.Progress + var bars = make(map[string]*mpb.Bar) + + for event := range events { + if event.Err != nil { + return event.Err + } + + if progress == nil { + progress = mpb.New(mpb.WithOutput(env.Err.Raw())) + } + + switch event.Event { + case cache.BuildEventCacheIsBuilt: + env.Err.Println("Building cache... ") + case cache.BuildEventStarted: + bars[event.Typename] = progress.AddBar(-1, + mpb.BarRemoveOnComplete(), + mpb.PrependDecorators( + decor.Name(event.Typename, decor.WCSyncSpace), + decor.CountersNoUnit("%d / %d", decor.WCSyncSpace), + ), + mpb.AppendDecorators(decor.Percentage(decor.WCSyncSpace)), + ) + case cache.BuildEventProgress: + bars[event.Typename].SetTotal(event.Total, false) + bars[event.Typename].SetCurrent(event.Progress) + } + } + + if progress != nil { + progress.Shutdown() + } + + return nil +} |