Expand a leading ~ in worktree paths to the home directory

Lazygit runs git directly rather than through a shell, so a literal "~"
reaches `git worktree add` unexpanded and git creates a directory named
"~" instead of using the home directory.

Expand the tilde ourselves, both for paths typed into the "Other"
location prompt and for the worktree.defaultPath config value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-07-03 18:53:05 +02:00
co-authored by Claude Opus 4.8
parent 737fb98967
commit 7a67cea687
8 changed files with 110 additions and 3 deletions
+1
View File
@@ -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
+1
View File
@@ -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"`
}
@@ -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
},
+1
View File
@@ -508,6 +508,7 @@ var tests = []*components.IntegrationTest{
worktree.BareRepoWorktreeConfig,
worktree.Crud,
worktree.CustomCommand,
worktree.DefaultPathTilde,
worktree.DetachWorktreeFromBranch,
worktree.DotfileBareRepo,
worktree.DoubleNestedLinkedSubmodule,
@@ -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()
})
},
})
+24
View File
@@ -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:])
}
+27
View File
@@ -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))
})
}
}
+1 -1
View File
@@ -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,