Compare commits

...
9 Commits
Author SHA1 Message Date
Stefan Haller 17cb09fa7b Avoid loading or migrating the user config in daemon mode (#6001)
When performing an interactive rebase, a child process of lazygit is
started as a rebase-todo editor, and sets (for example) the todo to
"edit" that you want to stop at. This child process would load the user
config as a normal lazygit does, and try to migrate it if it's out of
date. This is a problem when you maintain your dot files including
lazygit's config file in a git repo; if you rewrite the history of that
repo, and stop at a commit before the last config migration, the child
process tried to migrate the config mid-rebase, leading to confusing
behavior. Avoid this; there's no reason for the child process to load
the user config at all, it doesn't need it.

Note that if you stop in the rebase (e.g. because of a conflict), the
normal focus-in refresh of the lazygit main process will still try to
migrate the config at that time. This is the correct and expected
behavior, nothing to fix there; the file will show up as modified in the
Files panel, and needs to be discarded manually there before continuing.
2026-09-13 07:41:42 +02:00
Stefan Haller 4797734a1d Handle daemon mode before creating the app config
Daemon mode doesn't have any reason to read the user config or try to
migrate it if it's old.
2026-09-13 07:39:02 +02:00
Stefan Haller a54b3f0279 Construct logger separately and pass it into NewCommon
This is a preparation for passing only the logger to daemon.Handle
instead of the whole common.
2026-09-13 07:39:02 +02:00
Stefan Haller afbde5be3c Pass only log to daemon.Handle, not the whole common
The log is the only thing it needs.
2026-09-13 07:39:02 +02:00
Stefan Haller aac8bd4676 Fix Esc being handled with significant delay on some terminals (#6011)
Bump tcell to v3.5.0; this fixes a regression with Esc being handled
with a significant delay on some terminals.
2026-09-13 07:37:27 +02:00
Stefan Haller 70cfc565e7 Bump tcell to v3.5.0
This fixes a regression with Esc being handled with a significant delay
on some terminals.
2026-09-11 19:13:17 +02:00
Stefan Haller d0ede21e9c Allow running tests from a tarball (#6003)
Running integration tests from a tarball was never possible, but running
unit tests (`go test ./... -short`) was; this broke with v0.64.1
(specifically, with 34da956f5d). Make that possible again, and for all
tests now, including integration tests.
2026-09-09 09:30:10 +02:00
Stefan Haller 8b049be31d Find the lazygit root directory by go.mod instead of .git
Running the tests in an exported source tarball fails with "must run in
lazy project folder or child folder". GetLazyRootDirectory searches the
working directory and its parents for a .git directory, and a tarball
doesn't have one. This has always affected the integration tests; since
34da956f5d a unit test calls the function too, so now even
`go test ./... -short` fails.

Search for the go.mod file that declares lazygit's module instead. It
ships in tarballs, and there is exactly one of it per source tree.

Put the function in our own pkg/utils rather than change lazycore's; the
criterion is specific to lazygit, and I don't feel like making a change
to lazycore.

Return an error rather than call log.Fatal, and report it from the two
callers that run under `go test`. In a test binary, log.Fatal exits
without attributing the failure to any test. That is the failure mode
34da956f5d set out to remove. The remaining callers are development
tools that have nothing useful to do without the root directory; they
keep exiting, now through MustFindLazygitRootDirectory.

Also stop the search at the root of the file system rather than at "/".
On Windows the old loop walks up to "C:\" and then spins there forever.
2026-09-09 09:27:44 +02:00
Stefan Haller a8dc4aaf1b AGENTS.md addition 2026-09-07 09:51:49 +02:00
22 changed files with 345 additions and 84 deletions
+4
View File
@@ -120,6 +120,10 @@ separate, reviewable commit that the user decides when to fold in. A bare
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception.
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
you naturally would because "the branch isn't pushed yet". The user always wants
to review what you changed, so make this transparent; no exceptions.
**When the tip is the wrong place for a fixup, insert it mid-branch.**
Committing a fixup at the tip of the branch only works while the code it
touches still looks the same there; once later commits have rewritten that
+1 -1
View File
@@ -16,7 +16,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.2
github.com/gdamore/tcell/v3 v3.5.0
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
+2 -2
View File
@@ -30,8 +30,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU=
github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
github.com/gdamore/tcell/v3 v3.5.0 h1:SCp9czLv2K2aPORgD6+4fjV0xNAzKIkiezCkp6bHLe4=
github.com/gdamore/tcell/v3 v3.5.0/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+3 -4
View File
@@ -61,10 +61,9 @@ func Run(
}
}
func NewCommon(config config.AppConfigurer) (*common.Common, error) {
func NewCommon(config config.AppConfigurer, log *logrus.Entry) (*common.Common, error) {
userConfig := config.GetUserConfig()
appState := config.GetAppState()
log := newLogger(config)
// Initialize with English for the time being; the real translation set for
// the configured language will be read after reading the user config
tr := i18n.EnglishTranslationSet()
@@ -80,8 +79,8 @@ func NewCommon(config config.AppConfigurer) (*common.Common, error) {
return cmn, nil
}
func newLogger(cfg config.AppConfigurer) *logrus.Entry {
if cfg.GetDebug() {
func NewLogger(debug bool) *logrus.Entry {
if debug {
logPath, err := config.LogPath()
if err != nil {
log.Fatal(err)
+21 -22
View File
@@ -3,14 +3,13 @@ package daemon
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
@@ -66,14 +65,14 @@ func getInstruction() Instruction {
return mapping[getDaemonKind()](jsonData)
}
func Handle(common *common.Common) {
func Handle(log *logrus.Entry) {
if !InDaemonMode() {
return
}
instruction := getInstruction()
if err := instruction.run(common); err != nil {
if err := instruction.run(log); err != nil {
log.Fatal(err)
}
}
@@ -107,7 +106,7 @@ type Instruction interface {
SerializedInstructions() string
// runs the instruction
run(common *common.Common) error
run(log *logrus.Entry) error
}
func serializeInstruction[T any](instruction T) string {
@@ -147,7 +146,7 @@ func (self *ExitImmediatelyInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ExitImmediatelyInstruction) run(common *common.Common) error {
func (self *ExitImmediatelyInstruction) run(log *logrus.Entry) error {
return nil
}
@@ -165,8 +164,8 @@ func (self *RemoveUpdateRefsForCopiedBranchInstruction) SerializedInstructions()
return serializeInstruction(self)
}
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return nil
})
}
@@ -193,8 +192,8 @@ func (self *ChangeTodoActionsInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *ChangeTodoActionsInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange {
return utils.TodoChange{
Hash: c.Hash,
@@ -225,8 +224,8 @@ func (self *DropMergeCommitInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *DropMergeCommitInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *DropMergeCommitInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.DropMergeCommit(path, self.Hash, getCommentChar())
})
}
@@ -256,8 +255,8 @@ func (self *MoveFixupCommitDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *MoveFixupCommitDownInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveFixupCommitDown(path, self.OriginalHash, self.FixupHash, self.ChangeToFixup, getCommentChar())
})
}
@@ -282,14 +281,14 @@ func (self *MoveTodosUpInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosUpInstruction) run(common *common.Common) error {
func (self *MoveTodosUpInstruction) run(log *logrus.Entry) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(common, func(path string) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
@@ -314,14 +313,14 @@ func (self *MoveTodosDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosDownInstruction) run(common *common.Common) error {
func (self *MoveTodosDownInstruction) run(log *logrus.Entry) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(common, func(path string) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}
@@ -340,8 +339,8 @@ func (self *InsertBreakInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *InsertBreakInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *InsertBreakInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.PrependStrToTodoFile(path, []byte("break\n"))
})
}
@@ -364,8 +363,8 @@ func (self *WriteRebaseTodoInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *WriteRebaseTodoInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *WriteRebaseTodoInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return os.WriteFile(path, self.TodosFileContent, 0o644)
})
}
+5 -5
View File
@@ -5,9 +5,9 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -17,9 +17,9 @@ type ChangeTodoAction struct {
Flag string
}
func handleInteractiveRebase(common *common.Common, f func(path string) error) error {
common.Log.Info("Lazygit invoked as interactive rebase demon")
common.Log.Info("args: ", os.Args)
func handleInteractiveRebase(log *logrus.Entry, f func(path string) error) error {
log.Info("Lazygit invoked as interactive rebase demon")
log.Info("args: ", os.Args)
path := os.Args[1]
if strings.HasSuffix(path, "git-rebase-todo") {
@@ -32,7 +32,7 @@ func handleInteractiveRebase(common *common.Common, f func(path string) error) e
// 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 {
common.Log.Info("Lazygit demon did not match on any use cases")
log.Info("Lazygit demon did not match on any use cases")
}
return nil
+10 -6
View File
@@ -93,6 +93,15 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
env.SetGitDirEnv(cliArgs.GitDir)
}
// The log file lives in the config dir, so this must come after setting the
// CONFIG_DIR env var above.
logger := NewLogger(cliArgs.Debug)
if daemon.InDaemonMode() {
daemon.Handle(logger)
return
}
if cliArgs.PrintVersionInfo {
gitVersion := getGitVersionInfo()
fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s, git version=%s\n", buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, buildInfo.Version, runtime.GOOS, runtime.GOARCH, gitVersion)
@@ -154,16 +163,11 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
appConfig.SaveGlobalUserConfig()
}
common, err := NewCommon(appConfig)
common, err := NewCommon(appConfig, logger)
if err != nil {
log.Fatal(err)
}
if daemon.InDaemonMode() {
daemon.Handle(common)
return
}
if cliArgs.Profile {
go func() {
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
+4 -3
View File
@@ -19,12 +19,12 @@ import (
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -49,7 +49,7 @@ func CommandToRun() string {
}
func GetKeybindingsDir() string {
return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings"
}
func generateAtDir(cheatsheetDir string) {
@@ -58,10 +58,11 @@ func generateAtDir(cheatsheetDir string) {
log.Fatal(err)
}
mConfig := config.NewDummyAppConfig()
logger := app.NewLogger(mConfig.GetDebug())
for lang := range translationSetsByLang {
mConfig.GetUserConfig().Gui.Language = lang
common, err := app.NewCommon(mConfig)
common, err := app.NewCommon(mConfig, logger)
if err != nil {
log.Fatal(err)
}
+2 -2
View File
@@ -8,9 +8,9 @@ import (
"strconv"
"strings"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -54,7 +54,7 @@ func runAndPrintFatalError(test *components.IntegrationTest, f func() error) {
}
func getTestsToRun(testNames []string) []*components.IntegrationTest {
allIntegrationTests := tests.GetTests(utils.GetLazyRootDirectory())
allIntegrationTests := tests.GetTests(utils.MustFindLazygitRootDirectory())
var testsToRun []*components.IntegrationTest
if len(testNames) == 0 {
+8 -3
View File
@@ -16,9 +16,9 @@ import (
"time"
"github.com/creack/pty"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
@@ -37,8 +37,13 @@ func TestIntegration(t *testing.T) {
codeCoverageDir := os.Getenv("LAZYGIT_GOCOVERDIR")
testNumber := 0
err := components.RunTests(components.RunTestArgs{
Tests: tests.GetTests(utils.GetLazyRootDirectory()),
rootDir, err := utils.FindLazygitRootDirectory()
if err != nil {
t.Fatal(err)
}
err = components.RunTests(components.RunTestArgs{
Tests: tests.GetTests(rootDir),
Logf: t.Logf,
RunCmd: runCmdHeadless,
TestWrapper: func(test *components.IntegrationTest, f func() error) {
+5 -4
View File
@@ -9,12 +9,13 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazycore/pkg/utils"
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -23,7 +24,7 @@ import (
var SLOW_INPUT_DELAY = 600
func RunTUI(raceDetector bool) {
rootDir := utils.GetLazyRootDirectory()
rootDir := utils.MustFindLazygitRootDirectory()
testDir := filepath.Join(rootDir, "test", "integration")
app := newApp(testDir)
@@ -206,7 +207,7 @@ type app struct {
}
func newApp(testDir string) *app {
return &app{testDir: testDir, allTests: tests.GetTests(utils.GetLazyRootDirectory())}
return &app{testDir: testDir, allTests: tests.GetTests(utils.MustFindLazygitRootDirectory())}
}
func (self *app) getCurrentTest() *components.IntegrationTest {
@@ -224,7 +225,7 @@ func (self *app) loadTests() {
}
func (self *app) adjustCursor() {
self.itemIdx = utils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1)
self.itemIdx = lazycoreUtils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1)
}
func (self *app) filterWithString(needle string) {
+5 -3
View File
@@ -9,7 +9,6 @@ import (
"sync"
"time"
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -40,12 +39,15 @@ type RunTestArgs struct {
// showing what's actually happening during the test, but it's still good at running
// tests in telling you about their results.
func RunTests(args RunTestArgs) error {
projectRootDir := lazycoreUtils.GetLazyRootDirectory()
err := os.Chdir(projectRootDir)
projectRootDir, err := utils.FindLazygitRootDirectory()
if err != nil {
return err
}
if err := os.Chdir(projectRootDir); err != nil {
return err
}
testDir := filepath.Join(projectRootDir, "test", "_results")
if err := buildLazygit(args); err != nil {
return err
+7 -2
View File
@@ -5,13 +5,13 @@ import (
"path/filepath"
"testing"
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
@@ -227,7 +227,12 @@ func TestFailingFixture(t *testing.T) {
paths := NewPaths(t.TempDir())
assert.NoError(t, os.MkdirAll(paths.ActualRepo(), 0o777))
workingDir, err := createFixture(test, paths, lazycoreUtils.GetLazyRootDirectory())
rootDir, err := utils.FindLazygitRootDirectory()
if err != nil {
t.Fatal(err)
}
workingDir, err := createFixture(test, paths, rootDir)
assert.ErrorContains(t, err, "git checkout no-such-branch")
assert.Empty(t, workingDir)
+2 -2
View File
@@ -9,14 +9,14 @@ import (
"reflect"
"strings"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
)
func GetSchemaDir() string {
return utils.GetLazyRootDirectory() + "/schema-master"
return utils.MustFindLazygitRootDirectory() + "/schema-master"
}
func GenerateSchema() *jsonschema.Schema {
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"os"
"strings"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
@@ -163,7 +163,7 @@ func (n *Node) MarshalYAML() (any, error) {
}
func writeToConfigDocs(config []byte) error {
configPath := utils.GetLazyRootDirectory() + "/docs-master/Config.md"
configPath := utils.MustFindLazygitRootDirectory() + "/docs-master/Config.md"
markdown, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("Error reading Config.md file %w", err)
+69
View File
@@ -0,0 +1,69 @@
package utils
import (
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
"github.com/samber/lo"
)
// FindLazygitRootDirectory returns the root directory of the lazygit source
// tree, by searching the working directory and its parents for the go.mod file
// that declares lazygit's module. Only development tools use it: the
// integration test runner, the cheatsheet generator, and the JSON schema
// generator. Not to be confused with finding the root directory of the
// repository that lazygit is being run in.
//
// We search upwards rather than expect to be called from the root directory,
// because `go test` runs each test binary in the source directory of its
// package, not in the directory that `go test` was invoked from.
func FindLazygitRootDirectory() (string, error) {
startDir, err := os.Getwd()
if err != nil {
return "", err
}
dir := startDir
for {
if declaresLazygitModule(filepath.Join(dir, "go.mod")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf(
"failed to find the lazygit root directory: there is no go.mod for a lazygit module in %s or any of its parent directories",
startDir)
}
dir = parent
}
}
// MustFindLazygitRootDirectory is FindLazygitRootDirectory for tools that can't
// do anything useful if the directory isn't found.
func MustFindLazygitRootDirectory() string {
dir, err := FindLazygitRootDirectory()
if err != nil {
log.Fatal(err)
}
return dir
}
// A fork is free to rename the module, so we accept any module path with a
// "lazygit" element in it, e.g. github.com/jesseduffield/lazygit.
func declaresLazygitModule(goModPath string) bool {
contents, err := os.ReadFile(goModPath)
if err != nil {
return false
}
return lo.SomeBy(strings.Split(string(contents), "\n"), func(line string) bool {
fields := strings.Fields(line)
return len(fields) >= 2 && fields[0] == "module" &&
slices.Contains(strings.Split(fields[1], "/"), "lazygit")
})
}
+86
View File
@@ -0,0 +1,86 @@
package utils
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFindLazygitRootDirectory(t *testing.T) {
// This test runs in the pkg/utils directory, so we expect the function to
// search two levels up for the project root.
expectedRootDir, err := filepath.Abs(filepath.Join("..", ".."))
assert.NoError(t, err)
rootDir, err := FindLazygitRootDirectory()
assert.NoError(t, err)
assert.Equal(t, expectedRootDir, rootDir)
}
func TestFindLazygitRootDirectoryOutsideProject(t *testing.T) {
t.Chdir(t.TempDir())
_, err := FindLazygitRootDirectory()
assert.ErrorContains(t, err, "there is no go.mod for a lazygit module")
}
func TestDeclaresLazygitModule(t *testing.T) {
scenarios := []struct {
testName string
contents string
expected bool
}{
{
testName: "lazygit's go.mod",
contents: "module github.com/jesseduffield/lazygit\n\ngo 1.25.0\n",
expected: true,
},
{
testName: "a fork's go.mod",
contents: "module gitlab.com/somebody-else/lazygit\n\ngo 1.25.0\n",
expected: true,
},
{
testName: "a fork's go.mod with a major version suffix",
contents: "module github.com/somebody-else/lazygit/v2\n\ngo 1.25.0\n",
expected: true,
},
{
testName: "module declaration preceded by a comment",
contents: "// a comment\n\nmodule github.com/jesseduffield/lazygit\n",
expected: true,
},
{
testName: "module declaration followed by a comment",
contents: "module github.com/jesseduffield/lazygit // comment\n\ngo 1.25.0\n",
expected: true,
},
{
testName: "another project's go.mod",
contents: "module github.com/jesseduffield/lazydocker\n\ngo 1.25.0\n",
expected: false,
},
{
testName: "no module declaration",
contents: "go 1.25.0\n",
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "go.mod")
assert.NoError(t, os.WriteFile(path, []byte(scenario.contents), 0o644))
assert.Equal(t, scenario.expected, declaresLazygitModule(path))
})
}
}
func TestDeclaresLazygitModuleWithoutAGoModFile(t *testing.T) {
assert.False(t, declaresLazygitModule(filepath.Join(t.TempDir(), "go.mod")))
}
+42
View File
@@ -252,3 +252,45 @@ func (cb *CellBuffer) Fill(r rune, style Style) {
c.width = 1
}
}
// FillArea fills a rectangular region of the cell buffer with the specified
// character and style. The region starts at column x, row y and extends w
// columns to the right and h rows down; any part of it lying outside the
// buffer is simply skipped, so callers do not need to clip coordinates
// themselves. A zero or negative width or height fills nothing. As with
// Fill, this doesn't support combining characters or wide runes, and a
// ColorNone foreground or background leaves that color unchanged.
func (cb *CellBuffer) FillArea(x, y, w, h int, r rune, style Style) {
if w <= 0 || h <= 0 {
return
}
x0 := max(x, 0)
y0 := max(y, 0)
// Clip the far edge to the buffer without ever evaluating x+w or y+h when
// they would overflow a signed int. Because w and h are positive here,
// x < cb.w-w is equivalent to x+w < cb.w but cannot overflow, and it is
// only true when x+w is small enough to compute safely.
x1 := cb.w
if x < cb.w-w {
x1 = x + w
}
y1 := cb.h
if y < cb.h-h {
y1 = y + h
}
for row := y0; row < y1; row++ {
for col := x0; col < x1; col++ {
c := &cb.cells[(row*cb.w)+col]
c.currStr = string(r)
cs := style
if cs.fg == ColorNone {
cs.fg = c.currStyle.fg
}
if cs.bg == ColorNone {
cs.bg = c.currStyle.bg
}
c.currStyle = cs
c.width = 1
}
}
}
+37 -19
View File
@@ -64,9 +64,10 @@ const (
const defaultControlStringLimit = 64 * 1024
const (
// loneEscapeTimeout keeps bare Escape responsive when using legacy
// keyboard reporting, where ESC can also prefix an Alt-modified key.
loneEscapeTimeout = 200 * time.Millisecond
// loneEscapeTimeout keeps bare Escape responsive. A lone ESC byte is
// always ambiguous, because it can also prefix an Alt-modified key or a
// longer sequence, so it cannot be resolved until this expires.
loneEscapeTimeout = 50 * time.Millisecond
// escapeSequenceTimeout bounds incomplete escape sequences. Once a
// sequence introducer has arrived, it is no longer ambiguous with a lone
@@ -129,27 +130,15 @@ func asciiByteFromInt(n int) (byte, bool) {
return byte(n), true
}
// Waiting returns true if the processor is waiting for
// some more input (i.e. we are not in in the initial state.)
// This can occur when we have ambiguous escape sequences, such
// as the lone escape. If this is typed, we expect at least a minimal
// inter-key delay before the next stroke occurs, and the caller
// should check for waiting, and call Scan() or ScanUTF8() to
// finish the processing. (Typically after a delay of around 100ms.)
func (ip *inputParser) Waiting() bool {
ip.l.Lock()
defer ip.l.Unlock()
return ip.state != istInit
}
// waitDuration reports how long to wait for the next byte before resetting an
// incomplete escape sequence. A bare ESC is only ambiguous with legacy
// keyboard reporting; other protocols can use the longer sequence deadline.
// incomplete escape sequence. A bare ESC is ambiguous under every keyboard
// protocol, so it gets the short deadline; only once an introducer has arrived
// is the longer sequence deadline used.
func (ip *inputParser) waitDuration() time.Duration {
if ip.state == istInit {
return 0
}
if ip.state == istEsc && ip.legacy {
if ip.state == istEsc {
return loneEscapeTimeout
}
return escapeSequenceTimeout
@@ -867,6 +856,9 @@ func (ip *inputParser) handleXda(str string) {
}
func calcModifier(n int) ModMask {
if n < 1 {
return ModNone
}
n--
m := ModNone
if n&1 != 0 {
@@ -987,6 +979,26 @@ func kittyModifierKey(code int) ModMask {
}
}
// kittyKeyText extracts the associated text (kitty mode 16) from a csi-u
// event's params: the third ;-field, codepoints :separated. Empty when
// the event carries no text (control keys, specials, terminals without
// mode 16), so callers fall back to the base key.
func kittyKeyText(params string) string {
fields := strings.Split(params, ";")
if len(fields) < 3 || fields[2] == "" {
return ""
}
var b strings.Builder
// Reject C0 control chars, DEL, and C1 control chars: they must never
// surface as key text.
for cp := range strings.SplitSeq(fields[2], ":") {
if n, err := strconv.ParseInt(cp, 10, 32); err == nil && n >= 0x20 && (n < 0x7f || n > 0x9f) && utf8.ValidRune(rune(n)) {
b.WriteRune(rune(n))
}
}
return b.String()
}
func (ip *inputParser) handleMouse(mode rune, params []int) {
// XTerm mouse events only report at most one button at a time,
@@ -1421,6 +1433,12 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
if mod1 := kittyModifierKey(P0); mod1 != ModNone {
mod |= mod1
}
// kitty mode 16: text is the layout-correct output, sent with its
// modifiers - keep both. No text falls through to the base key.
if text := kittyKeyText(pstr); text != "" {
ip.postKeyEx(KeyRune, text, mod, pressed, physical, repeat)
return
}
if key != KeyRune {
ip.postKeyEx(key, "", mod, pressed, physical, repeat)
} else if chr != 0 {
+15
View File
@@ -39,6 +39,14 @@ type Screen interface {
// is called (or Sync).
Fill(rune, Style)
// FillArea fills a rectangular region of the screen with the given
// character and style. The region starts at column x, row y and
// extends width columns to the right and height rows down. Any part
// of the region outside the screen is ignored, so it's safe to pass
// coordinates that overflow the screen. Like Fill, the change is not
// visible until Show (or Sync) is called.
FillArea(x int, y int, width int, height int, r rune, style Style)
// Put writes the first grapheme of the given string with th
// given style at the given coordinates. (Only the first grapheme
// occupying either one or two cells is stored.) It returns the
@@ -426,6 +434,13 @@ func (b *baseScreen) Fill(r rune, style Style) {
b.Unlock()
}
func (b *baseScreen) FillArea(x, y, width, height int, r rune, style Style) {
cb := b.GetCells()
b.Lock()
cb.FillArea(x, y, width, height, r, style)
b.Unlock()
}
func (b *baseScreen) SetContent(x, y int, mainc rune, combc []rune, style Style) {
b.Put(x, y, string(append([]rune{mainc}, combc...)), style)
}
+14 -3
View File
@@ -179,7 +179,7 @@ const (
notifyDesktop777 = "\x1b]777;notify;%s;%s\x1b\\" // Most commonly supported
queryKittyKbd = "\x1b[?u" // Query for Kitty keyboard support
enableKittyKbd = "\x1b[=1u" // Technically this pushes
enableKittyKbdAdv = "\x1b[=15u" // disambiguation, events, alternate keys, all keys
enableKittyKbdAdv = "\x1b[=31u" // disambiguation, events, alternate keys, all keys, text
disableKittyKbd = "\x1b[=0u" // Technically this means pop previous mode
queryXTermKbd = "\x1b[?4m" // Query for XTerm modify other keys support
enableXTermKbd = "\x1b[>4;2m" // Enable modify other keys protocol
@@ -1374,13 +1374,24 @@ func (t *tScreen) inputLoop(stopQ chan struct{}) {
defer t.wg.Done()
for {
readDone := make(chan bool)
chunk := make([]byte, 128)
var n int
var e error
select {
case <-stopQ:
return
default:
go func() {
n, e = t.tty.Read(chunk)
close(readDone)
}()
select {
case <-stopQ:
return
case <-readDone:
}
}
chunk := make([]byte, 128)
n, e := t.tty.Read(chunk)
switch e {
case nil:
default:
+1 -1
View File
@@ -45,7 +45,7 @@ github.com/fatih/color
# github.com/gdamore/encoding v1.0.1
## explicit; go 1.9
github.com/gdamore/encoding
# github.com/gdamore/tcell/v3 v3.4.2
# github.com/gdamore/tcell/v3 v3.5.0
## explicit; go 1.25.0
github.com/gdamore/tcell/v3
github.com/gdamore/tcell/v3/color