diff --git a/docs-master/Config.md b/docs-master/Config.md index bf0f5c2c6..152044be6 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -533,6 +533,7 @@ worktree: # location alongside the parent directories of any worktrees you already have. # A relative path is resolved against the repository's root directory, so # "../worktrees" sits beside the repo and ".worktrees" sits inside it. + # A leading "~" is expanded to your home directory, so "~/worktrees" works. defaultPath: "" # Periodic update checks diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index f83e26ea3..8314701ba 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -427,6 +427,7 @@ type CommitPrefixConfig struct { type WorktreeConfig struct { // Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have. // A relative path is resolved against the repository's root directory, so "../worktrees" sits beside the repo and ".worktrees" sits inside it. + // A leading "~" is expanded to your home directory, so "~/worktrees" works. DefaultPath string `yaml:"defaultPath"` } diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 2bc877444..7a32d898c 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -378,7 +378,7 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str parentDirs := worktreeParentDirCandidates( self.c.Git().RepoPaths.RepoPath(), linkedWorktreePaths, - self.c.UserConfig().Worktree.DefaultPath, + utils.ExpandTilde(self.c.UserConfig().Worktree.DefaultPath), ) targets := lo.Map(parentDirs, func(parentDir string, _ int) string { @@ -398,7 +398,9 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewWorktreePath, InitialContent: targets[0], - HandleConfirm: onConfirm, + HandleConfirm: func(response string) error { + return onConfirm(utils.ExpandTilde(response)) + }, }) return nil }, diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 5aa8a21ee..136cd579b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -508,6 +508,7 @@ var tests = []*components.IntegrationTest{ worktree.BareRepoWorktreeConfig, worktree.Crud, worktree.CustomCommand, + worktree.DefaultPathTilde, worktree.DetachWorktreeFromBranch, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, diff --git a/pkg/integration/tests/worktree/default_path_tilde.go b/pkg/integration/tests/worktree/default_path_tilde.go new file mode 100644 index 000000000..57486f1a8 --- /dev/null +++ b/pkg/integration/tests/worktree/default_path_tilde.go @@ -0,0 +1,51 @@ +package worktree + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DefaultPathTilde = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A leading ~ in the worktree.defaultPath config is expanded to the home directory", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Worktree.DefaultPath = "~/my-worktrees" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("mybranch")). + Press(keys.Universal.NewWorktree). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'mybranch'")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + Type("newbranch"). + Confirm() + + // The default path's "~" is expanded to an absolute home-directory + // path; without expansion it would stay a literal "~" resolved + // against the repo, so the candidate would still contain a "~". + home, _ := os.UserHomeDir() + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + ContainsLines( + Contains(filepath.Join(home, "my-worktrees", "newbranch")).DoesNotContain("~"), + ). + Cancel() + }) + }, +}) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 40411d520..0494bb035 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "regexp" "runtime" "strconv" @@ -96,3 +97,26 @@ func FilePath(skip int) string { _, path, _, _ := runtime.Caller(skip) return path } + +// ExpandTilde expands a leading "~" that refers to the current user's home +// directory: "~" and "~/foo" become e.g. "/home/user" and "/home/user/foo". A +// tilde anywhere other than the start, or one immediately followed by a +// username ("~other/foo"), is left untouched, as is the path if the home +// directory can't be determined. We expand it ourselves because lazygit runs +// git directly, with no shell to do it for us. +func ExpandTilde(path string) string { + if path != "~" && !strings.HasPrefix(path, "~/") && + !(runtime.GOOS == "windows" && strings.HasPrefix(path, `~\`)) { + return path + } + + home, err := os.UserHomeDir() + if err != nil { + return path + } + + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 41b40cd9f..8304e7ba4 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -1,6 +1,8 @@ package utils import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -98,3 +100,28 @@ func TestModuloWithWrap(t *testing.T) { } } } + +func TestExpandTilde(t *testing.T) { + home, err := os.UserHomeDir() + assert.NoError(t, err) + + scenarios := []struct { + name string + path string + expected string + }{ + {"bare tilde", "~", home}, + {"tilde with subpath", "~/worktrees", filepath.Join(home, "worktrees")}, + {"absolute path is untouched", "/absolute/path", "/absolute/path"}, + {"relative path is untouched", "relative/path", "relative/path"}, + {"tilde not at the start is untouched", "/foo/~/bar", "/foo/~/bar"}, + {"tilde followed by a username is untouched", "~other/worktrees", "~other/worktrees"}, + {"empty string is untouched", "", ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, ExpandTilde(s.path)) + }) + } +} diff --git a/schema-master/config.json b/schema-master/config.json index 5a0af4eb4..0dd1d5d20 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3899,7 +3899,7 @@ "properties": { "defaultPath": { "type": "string", - "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it." + "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it.\nA leading \"~\" is expanded to your home directory, so \"~/worktrees\" works." } }, "additionalProperties": false,