From cd5b041b0f8b95b473849d222b71aa2fe3b24899 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sat, 7 May 2022 15:23:08 +1000 Subject: [PATCH] clearer separation of concerns when bootstrapping application --- main.go | 29 ++- pkg/app/app.go | 201 ++++-------------- pkg/app/daemon/daemon.go | 107 ++++++++++ pkg/app/errors.go | 39 ++++ pkg/app/logging.go | 68 ++++-- pkg/cheatsheet/generate.go | 6 +- pkg/commands/git_commands/rebase.go | 7 +- pkg/commands/git_commands/rebase_test.go | 3 +- pkg/env/env.go | 6 +- pkg/logs/logs.go | 34 +++ pkg/logs/logs_default.go | 31 +++ .../logs_windows.go} | 2 +- 12 files changed, 328 insertions(+), 205 deletions(-) create mode 100644 pkg/app/daemon/daemon.go create mode 100644 pkg/app/errors.go create mode 100644 pkg/logs/logs.go create mode 100644 pkg/logs/logs_default.go rename pkg/{app/logging_windows.go => logs/logs_windows.go} (99%) diff --git a/main.go b/main.go index 7ed434377..c1de86e30 100644 --- a/main.go +++ b/main.go @@ -8,12 +8,12 @@ import ( "path/filepath" "runtime" - "github.com/go-errors/errors" "github.com/integrii/flaggy" "github.com/jesseduffield/lazygit/pkg/app" + "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/env" + "github.com/jesseduffield/lazygit/pkg/logs" yaml "github.com/jesseduffield/yaml" ) @@ -117,7 +117,7 @@ func main() { } if logFlag { - app.TailLogs() + logs.TailLogs() os.Exit(0) } @@ -138,20 +138,15 @@ func main() { log.Fatal(err.Error()) } - app, err := app.NewApp(appConfig) - - if err == nil { - err = app.Run(filterPath) - } - + common, err := app.NewCommon(appConfig) if err != nil { - if errorMessage, known := app.KnownError(err); known { - log.Fatal(errorMessage) - } - newErr := errors.Wrap(err, 0) - stackTrace := newErr.ErrorStack() - app.Log.Error(stackTrace) - - log.Fatal(fmt.Sprintf("%s: %s\n\n%s", app.Tr.ErrorOccurred, constants.Links.Issues, stackTrace)) + log.Fatal(err) } + + if daemon.InDaemonMode() { + daemon.Handle(common) + return + } + + app.Run(appConfig, common, filterPath) } diff --git a/pkg/app/app.go b/pkg/app/app.go index 592505d60..d6f4c1928 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -2,10 +2,8 @@ package app import ( "bufio" - "errors" "fmt" "io" - "io/ioutil" "log" "os" "path/filepath" @@ -13,118 +11,81 @@ import ( "strconv" "strings" - "github.com/aybabtme/humanlog" + "github.com/go-errors/errors" + "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/updates" - "github.com/sirupsen/logrus" ) -// App struct +// App is the struct that's instantiated from within main.go and it manages +// bootstrapping and running the application. type App struct { *common.Common - closers []io.Closer - Config config.AppConfigurer - OSCommand *oscommands.OSCommand - Gui *gui.Gui - Updater *updates.Updater // may only need this on the Gui - ClientContext string + closers []io.Closer + Config config.AppConfigurer + OSCommand *oscommands.OSCommand + Gui *gui.Gui + Updater *updates.Updater // may only need this on the Gui } -type errorMapping struct { - originalError string - newError string -} +func Run(config config.AppConfigurer, common *common.Common, filterPath string) { + app, err := NewApp(config, common) -func newProductionLogger() *logrus.Logger { - log := logrus.New() - log.Out = ioutil.Discard - log.SetLevel(logrus.ErrorLevel) - return log -} + if err == nil { + err = app.Run(filterPath) + } -func getLogLevel() logrus.Level { - strLevel := os.Getenv("LOG_LEVEL") - level, err := logrus.ParseLevel(strLevel) if err != nil { - return logrus.DebugLevel + if errorMessage, known := knownError(common.Tr, err); known { + log.Fatal(errorMessage) + } + newErr := errors.Wrap(err, 0) + stackTrace := newErr.ErrorStack() + app.Log.Error(stackTrace) + + log.Fatal(fmt.Sprintf("%s: %s\n\n%s", common.Tr.ErrorOccurred, constants.Links.Issues, stackTrace)) } - return level } -func newDevelopmentLogger() *logrus.Logger { - logger := logrus.New() - logger.SetLevel(getLogLevel()) - logPath, err := config.LogPath() - if err != nil { - log.Fatal(err) - } - file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) - if err != nil { - log.Fatalf("Unable to log to log file: %v", err) - } - logger.SetOutput(file) - return logger -} - -func newLogger(config config.AppConfigurer) *logrus.Entry { - var log *logrus.Logger - if config.GetDebug() || os.Getenv("DEBUG") == "TRUE" { - log = newDevelopmentLogger() - } else { - log = newProductionLogger() - } - - // highly recommended: tail -f development.log | humanlog - // https://github.com/aybabtme/humanlog - log.Formatter = &logrus.JSONFormatter{} - - return log.WithFields(logrus.Fields{ - "debug": config.GetDebug(), - "version": config.GetVersion(), - "commit": config.GetCommit(), - "buildDate": config.GetBuildDate(), - }) -} - -// NewApp bootstrap a new application -func NewApp(config config.AppConfigurer) (*App, error) { +func NewCommon(config config.AppConfigurer) (*common.Common, error) { userConfig := config.GetUserConfig() - app := &App{ - closers: []io.Closer{}, - Config: config, - } var err error log := newLogger(config) tr, err := i18n.NewTranslationSetFromConfig(log, userConfig.Gui.Language) if err != nil { - return app, err + return nil, err } - app.Common = &common.Common{ + return &common.Common{ Log: log, Tr: tr, UserConfig: userConfig, Debug: config.GetDebug(), + }, nil +} + +// NewApp bootstrap a new application +func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) { + app := &App{ + closers: []io.Closer{}, + Config: config, + Common: common, } - // if we are being called in 'demon' mode, we can just return here - app.ClientContext = os.Getenv("LAZYGIT_CLIENT_COMMAND") - if app.ClientContext != "" { - return app, nil - } + app.OSCommand = oscommands.NewOSCommand(common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(app.Log)) - app.OSCommand = oscommands.NewOSCommand(app.Common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(log)) - - app.Updater, err = updates.NewUpdater(app.Common, config, app.OSCommand) + var err error + app.Updater, err = updates.NewUpdater(common, config, app.OSCommand) if err != nil { return app, err } @@ -141,7 +102,7 @@ func NewApp(config config.AppConfigurer) (*App, error) { gitConfig := git_config.NewStdCachedGitConfig(app.Log) - app.Gui, err = gui.NewGui(app.Common, config, gitConfig, app.Updater, showRecentRepos, dirName) + app.Gui, err = gui.NewGui(common, config, gitConfig, app.Updater, showRecentRepos, dirName) if err != nil { return app, err } @@ -243,97 +204,13 @@ func (app *App) setupRepo() (bool, error) { } func (app *App) Run(filterPath string) error { - if app.ClientContext == "INTERACTIVE_REBASE" { - return app.Rebase() - } - - if app.ClientContext == "EXIT_IMMEDIATELY" { - os.Exit(0) - } - err := app.Gui.RunAndHandleError(filterPath) return err } -func gitDir() string { - dir := env.GetGitDirEnv() - if dir == "" { - return ".git" - } - return dir -} - -// Rebase contains logic for when we've been run in demon mode, meaning we've -// given lazygit as a command for git to call e.g. to edit a file -func (app *App) Rebase() error { - app.Log.Info("Lazygit invoked as interactive rebase demon") - app.Log.Info("args: ", os.Args) - - if strings.HasSuffix(os.Args[1], "git-rebase-todo") { - if err := ioutil.WriteFile(os.Args[1], []byte(os.Getenv("LAZYGIT_REBASE_TODO")), 0o644); err != nil { - return err - } - } else if strings.HasSuffix(os.Args[1], filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test - // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG - // but in this case we don't need to edit it, so we'll just return - } else { - app.Log.Info("Lazygit demon did not match on any use cases") - } - - return nil -} - // Close closes any resources func (app *App) Close() error { return slices.TryForEach(app.closers, func(closer io.Closer) error { return closer.Close() }) } - -// KnownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace -func (app *App) KnownError(err error) (string, bool) { - errorMessage := err.Error() - - knownErrorMessages := []string{app.Tr.MinGitVersionError} - - if slices.Contains(knownErrorMessages, errorMessage) { - return errorMessage, true - } - - mappings := []errorMapping{ - { - originalError: "fatal: not a git repository", - newError: app.Tr.NotARepository, - }, - } - - if mapping, ok := slices.Find(mappings, func(mapping errorMapping) bool { - return strings.Contains(errorMessage, mapping.originalError) - }); ok { - return mapping.newError, true - } - - return "", false -} - -func TailLogs() { - logFilePath, err := config.LogPath() - if err != nil { - log.Fatal(err) - } - - fmt.Printf("Tailing log file %s\n\n", logFilePath) - - opts := humanlog.DefaultOptions - opts.Truncates = false - - _, err = os.Stat(logFilePath) - if err != nil { - if os.IsNotExist(err) { - log.Fatal("Log file does not exist. Run `lazygit --debug` first to create the log file") - } - log.Fatal(err) - } - - TailLogsForPlatform(logFilePath, opts) -} diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go new file mode 100644 index 000000000..ea71bb956 --- /dev/null +++ b/pkg/app/daemon/daemon.go @@ -0,0 +1,107 @@ +package daemon + +import ( + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/env" +) + +// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process. +// We do this when git lets us supply a program to run within a git command. +// For example, if we want to ensure that a git command doesn't hang due to +// waiting for an editor to save a commit message, we can tell git to invoke lazygit +// as the editor via 'GIT_EDITOR=lazygit', and use the env var +// 'LAZYGIT_DAEMON_KIND=EXIT_IMMEDIATELY' to specify that we want to run lazygit +// as a daemon which simply exits immediately. Any additional arguments we want +// to pass to a daemon can be done via other env vars. + +type DaemonKind string + +const ( + InteractiveRebase DaemonKind = "INTERACTIVE_REBASE" + ExitImmediately DaemonKind = "EXIT_IMMEDIATELY" +) + +const ( + DaemonKindEnvKey string = "LAZYGIT_DAEMON_KIND" + RebaseTODOEnvKey string = "LAZYGIT_REBASE_TODO" +) + +type Daemon interface { + Run() error +} + +func Handle(common *common.Common) { + d := getDaemon(common) + if d == nil { + return + } + + if err := d.Run(); err != nil { + log.Fatal(err) + } + + os.Exit(0) +} + +func InDaemonMode() bool { + return getDaemonKind() != "" +} + +func getDaemon(common *common.Common) Daemon { + switch getDaemonKind() { + case InteractiveRebase: + return &rebaseDaemon{c: common} + case ExitImmediately: + return &exitImmediatelyDaemon{c: common} + } + + return nil +} + +func getDaemonKind() DaemonKind { + return DaemonKind(os.Getenv(DaemonKindEnvKey)) +} + +type rebaseDaemon struct { + c *common.Common +} + +func (self *rebaseDaemon) Run() error { + self.c.Log.Info("Lazygit invoked as interactive rebase demon") + self.c.Log.Info("args: ", os.Args) + + if strings.HasSuffix(os.Args[1], "git-rebase-todo") { + if err := ioutil.WriteFile(os.Args[1], []byte(os.Getenv(RebaseTODOEnvKey)), 0o644); err != nil { + return err + } + } else if strings.HasSuffix(os.Args[1], filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test + // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG + // but in this case we don't need to edit it, so we'll just return + } else { + self.c.Log.Info("Lazygit demon did not match on any use cases") + } + + return nil +} + +func gitDir() string { + dir := env.GetGitDirEnv() + if dir == "" { + return ".git" + } + return dir +} + +type exitImmediatelyDaemon struct { + c *common.Common +} + +func (self *exitImmediatelyDaemon) Run() error { + return nil +} diff --git a/pkg/app/errors.go b/pkg/app/errors.go new file mode 100644 index 000000000..1556e58fe --- /dev/null +++ b/pkg/app/errors.go @@ -0,0 +1,39 @@ +package app + +import ( + "strings" + + "github.com/jesseduffield/generics/slices" + "github.com/jesseduffield/lazygit/pkg/i18n" +) + +type errorMapping struct { + originalError string + newError string +} + +// knownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace +func knownError(tr *i18n.TranslationSet, err error) (string, bool) { + errorMessage := err.Error() + + knownErrorMessages := []string{tr.MinGitVersionError} + + if slices.Contains(knownErrorMessages, errorMessage) { + return errorMessage, true + } + + mappings := []errorMapping{ + { + originalError: "fatal: not a git repository", + newError: tr.NotARepository, + }, + } + + if mapping, ok := slices.Find(mappings, func(mapping errorMapping) bool { + return strings.Contains(errorMessage, mapping.originalError) + }); ok { + return mapping.newError, true + } + + return "", false +} diff --git a/pkg/app/logging.go b/pkg/app/logging.go index 7df0bb0c2..db59a15d4 100644 --- a/pkg/app/logging.go +++ b/pkg/app/logging.go @@ -1,31 +1,61 @@ -//go:build !windows -// +build !windows - package app import ( + "io/ioutil" "log" "os" - "github.com/aybabtme/humanlog" - "github.com/jesseduffield/lazygit/pkg/secureexec" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/sirupsen/logrus" ) -func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { - cmd := secureexec.Command("tail", "-f", logFilePath) - - stdout, _ := cmd.StdoutPipe() - if err := cmd.Start(); err != nil { - log.Fatal(err) +func newLogger(config config.AppConfigurer) *logrus.Entry { + var log *logrus.Logger + if config.GetDebug() || os.Getenv("DEBUG") == "TRUE" { + log = newDevelopmentLogger() + } else { + log = newProductionLogger() } - if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil { - log.Fatal(err) - } + // highly recommended: tail -f development.log | humanlog + // https://github.com/aybabtme/humanlog + log.Formatter = &logrus.JSONFormatter{} - if err := cmd.Wait(); err != nil { - log.Fatal(err) - } - - os.Exit(0) + return log.WithFields(logrus.Fields{ + "debug": config.GetDebug(), + "version": config.GetVersion(), + "commit": config.GetCommit(), + "buildDate": config.GetBuildDate(), + }) +} + +func newProductionLogger() *logrus.Logger { + log := logrus.New() + log.Out = ioutil.Discard + log.SetLevel(logrus.ErrorLevel) + return log +} + +func newDevelopmentLogger() *logrus.Logger { + logger := logrus.New() + logger.SetLevel(getLogLevel()) + logPath, err := config.LogPath() + if err != nil { + log.Fatal(err) + } + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + log.Fatalf("Unable to log to log file: %v", err) + } + logger.SetOutput(file) + return logger +} + +func getLogLevel() logrus.Level { + strLevel := os.Getenv("LOG_LEVEL") + level, err := logrus.ParseLevel(strLevel) + if err != nil { + return logrus.DebugLevel + } + return level } diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 5852f127f..5117e6652 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -55,7 +55,11 @@ func generateAtDir(cheatsheetDir string) { for lang := range translationSetsByLang { mConfig.GetUserConfig().Gui.Language = lang - mApp, _ := app.NewApp(mConfig) + common, err := app.NewCommon(mConfig) + if err != nil { + log.Fatal(err) + } + mApp, _ := app.NewApp(mConfig, common) path := cheatsheetDir + "/Keybindings_" + lang + ".md" file, err := os.Create(path) if err != nil { diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index ab7963c44..e69c8b1bd 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "github.com/jesseduffield/generics/slices" + "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) @@ -109,8 +110,8 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo } cmdObj.AddEnvVars( - "LAZYGIT_CLIENT_COMMAND=INTERACTIVE_REBASE", - "LAZYGIT_REBASE_TODO="+todo, + daemon.DaemonKindEnvKey+"="+string(daemon.InteractiveRebase), + daemon.RebaseTODOEnvKey+"="+todo, "DEBUG="+debug, "LANG=en_US.UTF-8", // Force using EN as language "LC_ALL=en_US.UTF-8", // Force using EN as language @@ -297,7 +298,7 @@ func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) erro lazyGitPath := oscommands.GetLazygitPath() return cmdObj. AddEnvVars( - "LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY", + daemon.DaemonKindEnvKey+"="+string(daemon.ExitImmediately), "GIT_EDITOR="+lazyGitPath, "EDITOR="+lazyGitPath, "VISUAL="+lazyGitPath, diff --git a/pkg/commands/git_commands/rebase_test.go b/pkg/commands/git_commands/rebase_test.go index 4e7b5c2c6..c4d18000f 100644 --- a/pkg/commands/git_commands/rebase_test.go +++ b/pkg/commands/git_commands/rebase_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -61,7 +62,7 @@ func TestRebaseSkipEditorCommand(t *testing.T) { `^VISUAL=.*$`, `^EDITOR=.*$`, `^GIT_EDITOR=.*$`, - "^LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY$", + "^" + daemon.DaemonKindEnvKey + "=" + string(daemon.ExitImmediately) + "$", } { regexStr := regexStr foundMatch := lo.ContainsBy(envVars, func(envVar string) bool { diff --git a/pkg/env/env.go b/pkg/env/env.go index 9c0f4816f..8d7993a9a 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -1,6 +1,10 @@ package env -import "os" +import ( + "os" +) + +// This package encapsulates accessing/mutating the ENV of the program. func GetGitDirEnv() string { return os.Getenv("GIT_DIR") diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go new file mode 100644 index 000000000..a4fe94031 --- /dev/null +++ b/pkg/logs/logs.go @@ -0,0 +1,34 @@ +package logs + +import ( + "fmt" + "log" + "os" + + "github.com/aybabtme/humanlog" + "github.com/jesseduffield/lazygit/pkg/config" +) + +// TailLogs lets us run `lazygit --logs` to print the logs produced by other lazygit processes. +// This makes for easier debugging. +func TailLogs() { + logFilePath, err := config.LogPath() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Tailing log file %s\n\n", logFilePath) + + opts := humanlog.DefaultOptions + opts.Truncates = false + + _, err = os.Stat(logFilePath) + if err != nil { + if os.IsNotExist(err) { + log.Fatal("Log file does not exist. Run `lazygit --debug` first to create the log file") + } + log.Fatal(err) + } + + TailLogsForPlatform(logFilePath, opts) +} diff --git a/pkg/logs/logs_default.go b/pkg/logs/logs_default.go new file mode 100644 index 000000000..b4474720c --- /dev/null +++ b/pkg/logs/logs_default.go @@ -0,0 +1,31 @@ +//go:build !windows +// +build !windows + +package logs + +import ( + "log" + "os" + + "github.com/aybabtme/humanlog" + "github.com/jesseduffield/lazygit/pkg/secureexec" +) + +func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { + cmd := secureexec.Command("tail", "-f", logFilePath) + + stdout, _ := cmd.StdoutPipe() + if err := cmd.Start(); err != nil { + log.Fatal(err) + } + + if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil { + log.Fatal(err) + } + + if err := cmd.Wait(); err != nil { + log.Fatal(err) + } + + os.Exit(0) +} diff --git a/pkg/app/logging_windows.go b/pkg/logs/logs_windows.go similarity index 99% rename from pkg/app/logging_windows.go rename to pkg/logs/logs_windows.go index efbdfbbe1..7fa17db26 100644 --- a/pkg/app/logging_windows.go +++ b/pkg/logs/logs_windows.go @@ -1,7 +1,7 @@ //go:build windows // +build windows -package app +package logs import ( "bufio"