clearer separation of concerns when bootstrapping application

This commit is contained in:
Jesse Duffield 2022-05-07 15:23:08 +10:00
parent cf80978f15
commit cd5b041b0f
12 changed files with 328 additions and 205 deletions

25
main.go
View file

@ -8,12 +8,12 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"github.com/go-errors/errors"
"github.com/integrii/flaggy" "github.com/integrii/flaggy"
"github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/logs"
yaml "github.com/jesseduffield/yaml" yaml "github.com/jesseduffield/yaml"
) )
@ -117,7 +117,7 @@ func main() {
} }
if logFlag { if logFlag {
app.TailLogs() logs.TailLogs()
os.Exit(0) os.Exit(0)
} }
@ -138,20 +138,15 @@ func main() {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }
app, err := app.NewApp(appConfig) common, err := app.NewCommon(appConfig)
if err == nil {
err = app.Run(filterPath)
}
if err != nil { if err != nil {
if errorMessage, known := app.KnownError(err); known { log.Fatal(err)
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)) if daemon.InDaemonMode() {
daemon.Handle(common)
return
} }
app.Run(appConfig, common, filterPath)
} }

View file

@ -2,10 +2,8 @@ package app
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -13,21 +11,23 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/aybabtme/humanlog" "github.com/go-errors/errors"
"github.com/jesseduffield/generics/slices" "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/updates" "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 { type App struct {
*common.Common *common.Common
closers []io.Closer closers []io.Closer
@ -35,96 +35,57 @@ type App struct {
OSCommand *oscommands.OSCommand OSCommand *oscommands.OSCommand
Gui *gui.Gui Gui *gui.Gui
Updater *updates.Updater // may only need this on the Gui Updater *updates.Updater // may only need this on the Gui
ClientContext string
} }
type errorMapping struct { func Run(config config.AppConfigurer, common *common.Common, filterPath string) {
originalError string app, err := NewApp(config, common)
newError string
if err == nil {
err = app.Run(filterPath)
} }
func newProductionLogger() *logrus.Logger {
log := logrus.New()
log.Out = ioutil.Discard
log.SetLevel(logrus.ErrorLevel)
return log
}
func getLogLevel() logrus.Level {
strLevel := os.Getenv("LOG_LEVEL")
level, err := logrus.ParseLevel(strLevel)
if err != nil { 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 { func NewCommon(config config.AppConfigurer) (*common.Common, error) {
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) {
userConfig := config.GetUserConfig() userConfig := config.GetUserConfig()
app := &App{
closers: []io.Closer{},
Config: config,
}
var err error var err error
log := newLogger(config) log := newLogger(config)
tr, err := i18n.NewTranslationSetFromConfig(log, userConfig.Gui.Language) tr, err := i18n.NewTranslationSetFromConfig(log, userConfig.Gui.Language)
if err != nil { if err != nil {
return app, err return nil, err
} }
app.Common = &common.Common{ return &common.Common{
Log: log, Log: log,
Tr: tr, Tr: tr,
UserConfig: userConfig, UserConfig: userConfig,
Debug: config.GetDebug(), Debug: config.GetDebug(),
}, nil
} }
// if we are being called in 'demon' mode, we can just return here // NewApp bootstrap a new application
app.ClientContext = os.Getenv("LAZYGIT_CLIENT_COMMAND") func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
if app.ClientContext != "" { app := &App{
return app, nil closers: []io.Closer{},
Config: config,
Common: common,
} }
app.OSCommand = oscommands.NewOSCommand(app.Common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(log)) app.OSCommand = oscommands.NewOSCommand(common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(app.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 { if err != nil {
return app, err return app, err
} }
@ -141,7 +102,7 @@ func NewApp(config config.AppConfigurer) (*App, error) {
gitConfig := git_config.NewStdCachedGitConfig(app.Log) 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 { if err != nil {
return app, err return app, err
} }
@ -243,97 +204,13 @@ func (app *App) setupRepo() (bool, error) {
} }
func (app *App) Run(filterPath string) 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) err := app.Gui.RunAndHandleError(filterPath)
return err 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 // Close closes any resources
func (app *App) Close() error { func (app *App) Close() error {
return slices.TryForEach(app.closers, func(closer io.Closer) error { return slices.TryForEach(app.closers, func(closer io.Closer) error {
return closer.Close() 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)
}

107
pkg/app/daemon/daemon.go Normal file
View file

@ -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
}

39
pkg/app/errors.go Normal file
View file

@ -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
}

View file

@ -1,31 +1,61 @@
//go:build !windows
// +build !windows
package app package app
import ( import (
"io/ioutil"
"log" "log"
"os" "os"
"github.com/aybabtme/humanlog" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/secureexec" "github.com/sirupsen/logrus"
) )
func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { func newLogger(config config.AppConfigurer) *logrus.Entry {
cmd := secureexec.Command("tail", "-f", logFilePath) var log *logrus.Logger
if config.GetDebug() || os.Getenv("DEBUG") == "TRUE" {
log = newDevelopmentLogger()
} else {
log = newProductionLogger()
}
stdout, _ := cmd.StdoutPipe() // highly recommended: tail -f development.log | humanlog
if err := cmd.Start(); err != nil { // 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(),
})
}
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) log.Fatal(err)
} }
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil { if err != nil {
log.Fatal(err) log.Fatalf("Unable to log to log file: %v", err)
}
logger.SetOutput(file)
return logger
} }
if err := cmd.Wait(); err != nil { func getLogLevel() logrus.Level {
log.Fatal(err) strLevel := os.Getenv("LOG_LEVEL")
level, err := logrus.ParseLevel(strLevel)
if err != nil {
return logrus.DebugLevel
} }
return level
os.Exit(0)
} }

View file

@ -55,7 +55,11 @@ func generateAtDir(cheatsheetDir string) {
for lang := range translationSetsByLang { for lang := range translationSetsByLang {
mConfig.GetUserConfig().Gui.Language = lang 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" path := cheatsheetDir + "/Keybindings_" + lang + ".md"
file, err := os.Create(path) file, err := os.Create(path)
if err != nil { if err != nil {

View file

@ -8,6 +8,7 @@ import (
"github.com/go-errors/errors" "github.com/go-errors/errors"
"github.com/jesseduffield/generics/slices" "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/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
) )
@ -109,8 +110,8 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo
} }
cmdObj.AddEnvVars( cmdObj.AddEnvVars(
"LAZYGIT_CLIENT_COMMAND=INTERACTIVE_REBASE", daemon.DaemonKindEnvKey+"="+string(daemon.InteractiveRebase),
"LAZYGIT_REBASE_TODO="+todo, daemon.RebaseTODOEnvKey+"="+todo,
"DEBUG="+debug, "DEBUG="+debug,
"LANG=en_US.UTF-8", // Force using EN as language "LANG=en_US.UTF-8", // Force using EN as language
"LC_ALL=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() lazyGitPath := oscommands.GetLazygitPath()
return cmdObj. return cmdObj.
AddEnvVars( AddEnvVars(
"LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY", daemon.DaemonKindEnvKey+"="+string(daemon.ExitImmediately),
"GIT_EDITOR="+lazyGitPath, "GIT_EDITOR="+lazyGitPath,
"EDITOR="+lazyGitPath, "EDITOR="+lazyGitPath,
"VISUAL="+lazyGitPath, "VISUAL="+lazyGitPath,

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/go-errors/errors" "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/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
@ -61,7 +62,7 @@ func TestRebaseSkipEditorCommand(t *testing.T) {
`^VISUAL=.*$`, `^VISUAL=.*$`,
`^EDITOR=.*$`, `^EDITOR=.*$`,
`^GIT_EDITOR=.*$`, `^GIT_EDITOR=.*$`,
"^LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY$", "^" + daemon.DaemonKindEnvKey + "=" + string(daemon.ExitImmediately) + "$",
} { } {
regexStr := regexStr regexStr := regexStr
foundMatch := lo.ContainsBy(envVars, func(envVar string) bool { foundMatch := lo.ContainsBy(envVars, func(envVar string) bool {

6
pkg/env/env.go vendored
View file

@ -1,6 +1,10 @@
package env package env
import "os" import (
"os"
)
// This package encapsulates accessing/mutating the ENV of the program.
func GetGitDirEnv() string { func GetGitDirEnv() string {
return os.Getenv("GIT_DIR") return os.Getenv("GIT_DIR")

34
pkg/logs/logs.go Normal file
View file

@ -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)
}

31
pkg/logs/logs_default.go Normal file
View file

@ -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)
}

View file

@ -1,7 +1,7 @@
//go:build windows //go:build windows
// +build windows // +build windows
package app package logs
import ( import (
"bufio" "bufio"