From b02eca451c876e7c58b7ab8a3a8aa06386cc8630 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 08:13:26 +0200 Subject: [PATCH] Add helper to compute candidate worktree parent directories This is the core of "never type a path from scratch": from the repo root, the configured default path, and the parents of existing worktrees, derive the ordered list of directories under which a new worktree could be placed. Pure and unit-tested here; wired into the creation flow in a later commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/worktree_helper.go | 34 +++++++ .../helpers/worktree_helper_test.go | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 pkg/gui/controllers/helpers/worktree_helper_test.go diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 6cb22084b..5f5933746 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -2,6 +2,7 @@ package helpers import ( "errors" + "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -10,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type WorktreeHelper struct { @@ -219,6 +221,38 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { }) } +// worktreeParentDirCandidates returns the candidate parent directories in which +// to create a new worktree, in priority order and de-duplicated: +// +// 1. the parent directory of each existing linked worktree (in worktree order); +// 2. the configured default path (relative paths are resolved against repoPath); +// 3. the repo's parent directory, if nothing else is available. +// +// repoPath is RepoPaths.RepoPath(), which is stable regardless of which worktree +// we're currently standing in. All returned paths are absolute. +func worktreeParentDirCandidates(repoPath string, linkedWorktreePaths []string, defaultPath string) []string { + candidates := lo.Map(linkedWorktreePaths, func(path string, _ int) string { + return filepath.Dir(path) + }) + + if defaultPath != "" { + if filepath.IsAbs(defaultPath) { + defaultPath = filepath.Clean(defaultPath) + } else { + defaultPath = filepath.Join(repoPath, defaultPath) + } + candidates = append(candidates, defaultPath) + } + + candidates = lo.Uniq(candidates) + + if len(candidates) == 0 { + candidates = append(candidates, filepath.Dir(repoPath)) + } + + return candidates +} + func (self *WorktreeHelper) ViewWorktreeOptions(context types.IListContext, ref string) error { currentBranch := self.refsHelper.GetCheckedOutRef() canCheckoutBase := context == self.c.Contexts().Branches && ref != currentBranch.RefName() diff --git a/pkg/gui/controllers/helpers/worktree_helper_test.go b/pkg/gui/controllers/helpers/worktree_helper_test.go new file mode 100644 index 000000000..ea975d79d --- /dev/null +++ b/pkg/gui/controllers/helpers/worktree_helper_test.go @@ -0,0 +1,94 @@ +package helpers + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// nativePath rewrites a forward-slash test path into one that is valid on the +// host OS, so the scenarios below can be written with readable Unix-style +// paths. On Windows a leading slash is not absolute (filepath.IsAbs wants a +// drive letter), so we graft one on; relative paths are left untouched. +func nativePath(p string) string { + if runtime.GOOS == "windows" && strings.HasPrefix(p, "/") { + p = "C:" + p + } + return filepath.FromSlash(p) +} + +func TestWorktreeParentDirCandidates(t *testing.T) { + scenarios := []struct { + name string + repoPath string + linkedWorktreePaths []string + defaultPath string + expected []string + }{ + { + name: "no worktrees and no default path falls back to the repo's parent", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: "", + expected: []string{"/code"}, + }, + { + name: "uses the parent of each linked worktree, in order", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo", "/elsewhere/bar"}, + defaultPath: "", + expected: []string{"/code/worktrees", "/elsewhere"}, + }, + { + name: "de-duplicates parents shared by multiple worktrees", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo", "/code/worktrees/bar"}, + defaultPath: "", + expected: []string{"/code/worktrees"}, + }, + { + name: "appends the default path after the worktree parents", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo"}, + defaultPath: "/somewhere/else", + expected: []string{"/code/worktrees", "/somewhere/else"}, + }, + { + name: "resolves a relative default path against the repo path", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: "../worktrees", + expected: []string{"/code/worktrees"}, + }, + { + name: "resolves a dot-relative default path inside the repo", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: ".worktrees", + expected: []string{"/code/myrepo/.worktrees"}, + }, + { + name: "de-duplicates the default path against a worktree parent", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo"}, + defaultPath: "/code/worktrees", + expected: []string{"/code/worktrees"}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := worktreeParentDirCandidates( + nativePath(s.repoPath), + lo.Map(s.linkedWorktreePaths, func(p string, _ int) string { return nativePath(p) }), + nativePath(s.defaultPath), + ) + expected := lo.Map(s.expected, func(p string, _ int) string { return nativePath(p) }) + assert.Equal(t, expected, result) + }) + } +}