From 1f37fc988e8019b6f8a54c7af4f5dfe90bc17864 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 6 Oct 2026 14:16:18 -0500 Subject: [PATCH 1/4] Discover registered worktrees independently of checkout location Git's worktree records allow every command to find checkouts outside the default layout. Keep branch identity separate from the public worktree name so directory-derived names do not change Git operations. Preserve names for existing standard-layout checkouts and reserve explicit names recorded in each checkout's Git directory. Manual checkouts use progressively longer path suffixes to resolve conflicts within a repository. --- internal/timber/generate_test.go | 16 ++-- internal/timber/manual_worktree_test.go | 75 +++++++++++++++++ internal/timber/remove.go | 2 +- .../timber/repo_remove_functional_test.go | 2 +- internal/timber/runtime.go | 84 ++++++++----------- internal/timber/switch.go | 14 +++- internal/timber/tui.go | 15 +++- internal/timber/tui_functional_test.go | 60 ++++++++----- internal/timber/worktree.go | 5 ++ internal/timber/worktree_identity.go | 76 +++++++++++++++++ 10 files changed, 260 insertions(+), 89 deletions(-) create mode 100644 internal/timber/manual_worktree_test.go create mode 100644 internal/timber/worktree_identity.go diff --git a/internal/timber/generate_test.go b/internal/timber/generate_test.go index 50d12dd..196e462 100644 --- a/internal/timber/generate_test.go +++ b/internal/timber/generate_test.go @@ -286,16 +286,16 @@ func TestGeneratedSwitchCompletesWorktreeNamesAcrossRepos(t *testing.T) { } skipIfNoPty(t) - home := resolvedTempDir(t) - dataHome := filepath.Join(home, ".local", "share") - worktreeRoot := filepath.Join(home, "worktrees") - require.NoError(t, os.MkdirAll(filepath.Join(dataHome, "timber", "repos", "timber.git"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dataHome, "timber", "repos", "other.git"), 0o755)) + repository := newTestRepository(t) + home := repository.home + dataHome := repository.runtime.DataHome + worktreeRoot := repository.worktreeRoot + registerAdditionalRepo(t, repository, "timber") + registerAdditionalRepo(t, repository, "other") makeWorktree := func(repoName, worktreeName string) { t.Helper() - worktreePath := filepath.Join(worktreeRoot, repoName, worktreeName, repoName) - require.NoError(t, os.MkdirAll(worktreePath, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(worktreePath, ".git"), nil, 0o644)) + result := repository.runTimber(t, "create", at(repoName, worktreeName)) + require.NoError(t, result.err, result.stderr) } makeWorktree("timber", "feature/login") makeWorktree("other", "feature/api") diff --git a/internal/timber/manual_worktree_test.go b/internal/timber/manual_worktree_test.go new file mode 100644 index 0000000..1d2cae5 --- /dev/null +++ b/internal/timber/manual_worktree_test.go @@ -0,0 +1,75 @@ +package timber + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManualWorktreeLifecycle(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(resolvedTempDir(t), "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "feature/manual", path, "origin/main") + + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + require.Len(t, records, 1) + assert.Equal(t, "checkout", records[0].Name) + assert.Equal(t, path, records[0].Path) + assert.True(t, records[0].Merged) + + for _, name := range []string{"checkout", at(testRepoName, "checkout")} { + switched := repo.runTimber(t, "switch", name) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path, strings.TrimSpace(switched.stdout)) + } + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "check"), "checkout\n") + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "checkout@"), at(testRepoName, "checkout")) + + todo := repo.runTimber(t, "todo", "--path", "checkout") + require.NoError(t, todo.err, todo.stderr) + gitDir := strings.TrimSpace(runGitCommand(t, path, "rev-parse", "--absolute-git-dir")) + assert.Equal(t, filepath.Join(gitDir, "TODO.md"), strings.TrimSpace(todo.stdout)) + + pruned := repo.runTimber(t, "prune", "--dry-run") + require.NoError(t, pruned.err, pruned.stderr) + assert.Contains(t, pruned.stderr, "would prune checkout") + + removed := repo.runTimber(t, "remove", "checkout") + require.NoError(t, removed.err, removed.stderr) + assert.NoDirExists(t, path) + _, err := runGitCommandResult(repo.barePath, "show-ref", "--verify", "refs/heads/feature/manual") + assert.Error(t, err) +} + +func TestManualWorktreeNamesExpandPastConflicts(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + require.NoError(t, repo.runTimber(t, "create", at(testRepoName, "checkout")).err) + root := resolvedTempDir(t) + for _, group := range []string{"one", "two"} { + path := filepath.Join(root, group, "project", "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "branch-"+group, path, "origin/main") + } + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + var names []string + for _, record := range records { + names = append(names, record.Name) + } + assert.ElementsMatch(t, []string{"checkout", "one/project/checkout", "two/project/checkout"}, names) + for _, group := range []string{"one", "two"} { + switched := repo.runTimber(t, "switch", group+"/project/checkout") + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, filepath.Join(root, group, "project", "checkout"), strings.TrimSpace(switched.stdout)) + } +} diff --git a/internal/timber/remove.go b/internal/timber/remove.go index 509877b..debaa58 100644 --- a/internal/timber/remove.go +++ b/internal/timber/remove.go @@ -84,7 +84,7 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string return err } if branchExists { - if _, err := repository.git("branch", branchDeleteFlag(force), name); err != nil { + if _, err := repository.git("branch", branchDeleteFlag(force), worktree.branchName()); err != nil { return err } } diff --git a/internal/timber/repo_remove_functional_test.go b/internal/timber/repo_remove_functional_test.go index adb6a71..89a26de 100644 --- a/internal/timber/repo_remove_functional_test.go +++ b/internal/timber/repo_remove_functional_test.go @@ -12,7 +12,7 @@ func TestFunctionalRepoRemoveRefusesUnmanagedLinkedWorktrees(t *testing.T) { t.Parallel() repository := newTestRepository(t) unmanaged := filepath.Join(resolvedTempDir(t), "unmanaged") - runGitCommand(t, repository.barePath, "worktree", "add", unmanaged, "main") + runGitCommand(t, repository.barePath, "worktree", "add", "--detach", unmanaged, "main") head := runGitCommand(t, unmanaged, "rev-parse", "HEAD") result := repository.runTimber(t, "repo", "remove", testRepoName) diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 6c25023..43eeb7f 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -466,14 +466,12 @@ func (x Runtime) inferUniqueRepoForWorktree(worktreeName string) (string, error) var matches []string for _, repo := range repos { - worktreePath := x.managedWorktreePath(repo.Name, worktreeName) - _, err := os.Stat(worktreePath) - if err == nil { - matches = append(matches, repo.Name) - continue + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return "", err } - if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("inspect worktree directory %q: %w", worktreePath, err) + if _, err := managedWorktreeByName(worktrees, worktreeName); err == nil { + matches = append(matches, repo.Name) } } @@ -539,7 +537,11 @@ func (x Runtime) completeRepoSuffix(worktreeName string, repoPrefix string, requ continue } if requireWorktree { - if _, err := os.Stat(x.managedWorktreePath(repo.Name, worktreeName)); err != nil { + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + if _, err := managedWorktreeByName(worktrees, worktreeName); err != nil { continue } } @@ -558,7 +560,15 @@ func (x Runtime) completeWorktreeNamesAcrossRepos(toComplete string) ([]string, reposForName := make(map[string][]string) var names []string for _, repo := range repos { - for _, name := range x.managedWorktreeNamesOnDisk(repo.Name, toComplete) { + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + for _, worktree := range worktrees { + name := worktree.Name + if !strings.HasPrefix(name, toComplete) { + continue + } if _, exists := reposForName[name]; !exists { names = append(names, name) } @@ -656,37 +666,12 @@ func (x Runtime) openRegisteredRepository(name string) (*Repository, registeredR return repository, repo, nil } -// managedWorktreeNamesOnDisk lists worktree names under the managed root for repoName -// (layout: ///), filtered by toComplete prefix. -func (x Runtime) managedWorktreeNamesOnDisk(repoName string, toComplete string) []string { - repoRoot := filepath.Join(x.worktreeRoot(), filepath.FromSlash(repoName)) - shortName := repoShortName(repoName) - var names []string - _ = filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return nil - } - if !entry.IsDir() { - return nil - } - if entry.Name() != shortName { - return nil - } - if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { - return nil - } - parent := filepath.Dir(path) - name, err := filepath.Rel(repoRoot, parent) - if err != nil || name == "." || strings.HasPrefix(name, "..") { - return nil - } - if strings.HasPrefix(name, toComplete) { - names = append(names, name) - } - return filepath.SkipDir - }) - slices.Sort(names) - return names +func (x Runtime) worktreesForRepo(repo registeredRepo) ([]managedWorktree, error) { + repository, err := openBareRepository(x, repo.BarePath) + if err != nil { + return nil, err + } + return x.managedWorktreesFromRepository(repository, repo.Name) } func (x Runtime) completeRegisteredRepoNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -953,12 +938,12 @@ func (x Runtime) enrichManagedWorktree(repository *Repository, worktree managedW worktree.Status = status worktree.Clean = clean - upstreamRef, err := repository.upstreamReference(worktree.Name) + upstreamRef, err := repository.upstreamReference(worktree.branchName()) if err != nil { return managedWorktree{}, err } - merged, err := repository.branchMergedToUpstream(worktree.Name, worktree.BranchReference, upstreamRef) + merged, err := repository.branchMergedToUpstream(worktree.branchName(), worktree.BranchReference, upstreamRef) if err != nil { return managedWorktree{}, err } @@ -984,18 +969,14 @@ func (x Runtime) managedWorktreesFromRepository(repository *Repository, repoName continue } - expectedPath := x.managedWorktreePath(repoName, branchName) - same, err := samePath(expectedPath, porcelainWorktree.Path) + name, err := x.recordedWorktreeName(repoName, porcelainWorktree.Path, branchName) if err != nil { return nil, err } - if !same { - continue - } managedWorktrees = append(managedWorktrees, managedWorktree{ Repo: repoName, - Name: branchName, + Name: name, Path: porcelainWorktree.Path, DisplayPath: currentRelativePath(currentDirectory, porcelainWorktree.Path), CommitHash: porcelainWorktree.CommitHash, @@ -1003,6 +984,7 @@ func (x Runtime) managedWorktreesFromRepository(repository *Repository, repoName }) } + assignManualWorktreeNames(managedWorktrees) slices.SortFunc(managedWorktrees, compareManagedWorktrees) return managedWorktrees, nil @@ -1071,8 +1053,8 @@ func (x Runtime) enrichWorktreeForList(repository *Repository, worktree managedW // A branch without a resolvable upstream is not merged anywhere timber // tracks; list stays read-only and reports it as unmerged. - if upstreamRef, err := repository.upstreamReference(worktree.Name); err == nil { - merged, err := repository.branchMergedToUpstream(worktree.Name, worktree.BranchReference, upstreamRef) + if upstreamRef, err := repository.upstreamReference(worktree.branchName()); err == nil { + merged, err := repository.branchMergedToUpstream(worktree.branchName(), worktree.BranchReference, upstreamRef) if err != nil { return managedWorktree{}, err } @@ -1127,7 +1109,7 @@ func (x Runtime) enrichListedWorktreesWithPullRequests(ctx context.Context, work return err } for _, index := range byRepo[repoName] { - worktrees[index].PullRequest = pullRequests[worktrees[index].Name] + worktrees[index].PullRequest = pullRequests[worktrees[index].branchName()] } } return nil diff --git a/internal/timber/switch.go b/internal/timber/switch.go index dbe66e9..d546ca9 100644 --- a/internal/timber/switch.go +++ b/internal/timber/switch.go @@ -62,7 +62,19 @@ func (x *switchCommandOptions) Execute(command *cobra.Command, args []string) er return err } - worktreePath := x.runtime.managedWorktreePath(repoName, name) + repository, _, err := x.runtime.openRegisteredRepository(repoName) + if err != nil { + return err + } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, repoName) + if err != nil { + return err + } + worktree, err := managedWorktreeByName(worktrees, name) + if err != nil { + return fmt.Errorf("worktree %s not found in repository %s", name, repoName) + } + worktreePath := worktree.Path if _, err := os.Stat(worktreePath); err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("worktree %s not found at %s", name, worktreePath) diff --git a/internal/timber/tui.go b/internal/timber/tui.go index d81a94d..f651f33 100644 --- a/internal/timber/tui.go +++ b/internal/timber/tui.go @@ -114,10 +114,17 @@ func (x *tuiCreateCommandOptions) openSelectedWorktree( command *cobra.Command, selection createWizardSelection, ) error { - worktree := managedWorktree{ - Repo: selection.repoName, - Name: selection.worktreeName, - Path: x.runtime.managedWorktreePath(selection.repoName, selection.worktreeName), + repository, _, err := x.runtime.openRegisteredRepository(selection.repoName) + if err != nil { + return err + } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, selection.repoName) + if err != nil { + return err + } + worktree, err := managedWorktreeByName(worktrees, selection.worktreeName) + if err != nil { + return err } if err := x.runtime.openHerdrSpace(command.Context(), worktree); err != nil { return err diff --git a/internal/timber/tui_functional_test.go b/internal/timber/tui_functional_test.go index 1c820e2..99ab8ad 100644 --- a/internal/timber/tui_functional_test.go +++ b/internal/timber/tui_functional_test.go @@ -106,30 +106,44 @@ func TestTUICreateWithHerdrOpensStandardHerdrSpace(t *testing.T) { func TestTUICreateOpensSelectedWorktreeInHerdrSpace(t *testing.T) { t.Parallel() - const branchName = "feature/ui-open" - - testRepository := newTestRepository(t) - require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) - logPath := filepath.Join(resolvedTempDir(t), "herdr.log") - testRepository.runtime.HerdrExecutable = installFakeHerdrSpace(t, logPath) - testRepository.runtime.GhExecutable = installFakeGh(t) - - prompter := &stubCreateWizardPrompter{ - selection: createWizardSelection{ - action: wizardActionOpen, - repoName: testRepoName, - worktreeName: branchName, - }, + for _, layout := range []string{"standard", "manual"} { + t.Run(layout, func(t *testing.T) { + t.Parallel() + const branchName = "feature/ui-open" + testRepository := newTestRepository(t) + name := branchName + path := testRepository.worktreePath(branchName) + if layout == "manual" { + name = "checkout" + path = filepath.Join(resolvedTempDir(t), name) + runGitCommand(t, testRepository.barePath, "worktree", "add", "-b", branchName, path, "origin/main") + } else { + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + } + logPath := filepath.Join(resolvedTempDir(t), "herdr.log") + testRepository.runtime.HerdrExecutable = installFakeHerdrSpace(t, logPath) + testRepository.runtime.GhExecutable = installFakeGh(t) + + prompter := &stubCreateWizardPrompter{ + selection: createWizardSelection{ + action: wizardActionOpen, + repoName: testRepoName, + worktreeName: name, + }, + } + options := &tuiCreateCommandOptions{runtime: testRepository.runtime} + result := runTUICreate(t, options, prompter) + + require.NoError(t, result.err, result.stderr) + assert.Contains(t, result.stderr, "opened herdr space for "+name) + calls := readFakeHerdrLog(t, logPath) + assert.Len(t, calls, 11) + assert.Contains(t, calls, fakeHerdrLogLine("worktree", "open", "--workspace", "w1", "--path", path, "--label", name, "--no-focus")) + require.Len(t, prompter.worktrees, 1) + assert.Equal(t, testRepoName, prompter.worktrees[0].Repo) + assert.Equal(t, name, prompter.worktrees[0].Name) + }) } - options := &tuiCreateCommandOptions{runtime: testRepository.runtime} - result := runTUICreate(t, options, prompter) - - require.NoError(t, result.err, result.stderr) - assert.Contains(t, result.stderr, "opened herdr space for "+branchName) - assert.Len(t, readFakeHerdrLog(t, logPath), 11) - require.Len(t, prompter.worktrees, 1) - assert.Equal(t, testRepoName, prompter.worktrees[0].Repo) - assert.Equal(t, branchName, prompter.worktrees[0].Name) } func TestTUICreateWithNoHerdrDoesNotInvokeHerdr(t *testing.T) { diff --git a/internal/timber/worktree.go b/internal/timber/worktree.go index 1b449e9..b6d8a04 100644 --- a/internal/timber/worktree.go +++ b/internal/timber/worktree.go @@ -4,6 +4,7 @@ import ( "cmp" "fmt" "slices" + "strings" "time" ) @@ -26,6 +27,10 @@ type managedWorktree struct { PullRequest string } +func (x managedWorktree) branchName() string { + return strings.TrimPrefix(string(x.BranchReference), branchRefPrefix) +} + func (x managedWorktree) shortCommitHash() string { if len(x.CommitHash) <= 7 { return x.CommitHash diff --git a/internal/timber/worktree_identity.go b/internal/timber/worktree_identity.go new file mode 100644 index 0000000..d50cea0 --- /dev/null +++ b/internal/timber/worktree_identity.go @@ -0,0 +1,76 @@ +package timber + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const worktreeNameFile = "timber-name" + +// Standard-layout worktrees predate explicit name records. An empty name +// identifies a manual checkout whose name must be derived from its path. +func (x Runtime) recordedWorktreeName(repoName, path, branch string) (string, error) { + same, err := samePath(path, x.managedWorktreePath(repoName, branch)) + if err != nil { + return "", err + } + if same { + return branch, nil + } + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return "", nil + } + gitDir, err := x.worktreeGitDir(path) + if err != nil { + return "", err + } + content, err := os.ReadFile(filepath.Join(gitDir, worktreeNameFile)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read worktree name: %w", err) + } + return strings.TrimSpace(string(content)), nil +} + +// Expand all conflicting manual names together, reserving explicit names. +// Distinct absolute paths eventually yield distinct names, even when one +// checkout's path is a suffix of another's. +func assignManualWorktreeNames(worktrees []managedWorktree) { + manual := make([]bool, len(worktrees)) + parents := make([]string, len(worktrees)) + for index := range worktrees { + if worktrees[index].Name == "" { + manual[index] = true + worktrees[index].Name = filepath.Base(worktrees[index].Path) + parents[index] = filepath.Dir(worktrees[index].Path) + } + } + for { + counts := make(map[string]int) + for _, worktree := range worktrees { + counts[worktree.Name]++ + } + changed := false + for index := range worktrees { + if !manual[index] || counts[worktrees[index].Name] < 2 { + continue + } + parent := parents[index] + if parent == string(filepath.Separator) { + worktrees[index].Name = "/" + worktrees[index].Name + } else { + worktrees[index].Name = filepath.Base(parent) + "/" + worktrees[index].Name + } + parents[index] = filepath.Dir(parent) + changed = true + } + if !changed { + return + } + } +} From 49eb783c29c71b69d09d5fe7ba5eea93ae9bfc27 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 6 Oct 2026 14:17:42 -0500 Subject: [PATCH 2/4] Preserve custom checkout locations during removal and pruning Custom checkout parents belong to the user, not Timber's layout, so removal must leave them intact. Prune retains selected worktree identities because removing a conflicting checkout can change the directory-derived names of the remaining checkouts. Force removal must also work for manual branches without an upstream; only non-force removal needs cleanliness and merge checks. Verify that repository renames retain custom paths and keep them addressable. --- .../timber/custom_worktree_remove_test.go | 55 +++++++++++++++++++ internal/timber/prune.go | 9 ++- internal/timber/prune_test.go | 2 +- internal/timber/remove.go | 38 +++++++++---- .../timber/repo_rename_functional_test.go | 6 +- internal/timber/runtime.go | 9 +++ internal/timber/test_helpers_test.go | 13 ++++- 7 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 internal/timber/custom_worktree_remove_test.go diff --git a/internal/timber/custom_worktree_remove_test.go b/internal/timber/custom_worktree_remove_test.go new file mode 100644 index 0000000..f5fdb9d --- /dev/null +++ b/internal/timber/custom_worktree_remove_test.go @@ -0,0 +1,55 @@ +package timber + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomWorktreeRemovalPreservesParents(t *testing.T) { + t.Parallel() + for _, command := range []string{"remove", "prune"} { + t.Run(command, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + // Empty parents inside HOME would previously be removed along + // with the checkout. Two conflicts also exercise prune's snapshot. + for _, group := range []string{"one", "two"} { + path := filepath.Join(repo.home, "projects", group, "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "feature/"+group, path, "origin/main") + } + args := []string{command} + if command == "remove" { + args = append(args, "one/checkout") + } + result := repo.runTimber(t, args...) + require.NoError(t, result.err, result.stderr) + assert.NoDirExists(t, filepath.Join(repo.home, "projects", "one", "checkout")) + assert.DirExists(t, filepath.Join(repo.home, "projects", "one")) + if command == "prune" { + assert.NoDirExists(t, filepath.Join(repo.home, "projects", "two", "checkout")) + assert.DirExists(t, filepath.Join(repo.home, "projects", "two")) + } + }) + } +} + +func TestManualWorktreeWithoutUpstreamRequiresForceRemoval(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "projects", "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "manual-no-upstream", path, "main") + runGitCommandAllowError(t, repo.barePath, "branch", "--unset-upstream", "manual-no-upstream") + pruned := repo.runTimber(t, "prune") + require.NoError(t, pruned.err, pruned.stderr) + assert.DirExists(t, path) + rejected := repo.runTimber(t, "remove", "checkout") + require.Error(t, rejected.err) + assert.Contains(t, rejected.err.Error(), "has no upstream branch") + result := repo.runTimber(t, "remove", "--force", "checkout") + require.NoError(t, result.err, result.stderr) + assert.NoDirExists(t, path) + assert.DirExists(t, filepath.Dir(path)) +} diff --git a/internal/timber/prune.go b/internal/timber/prune.go index 22218fb..57a089a 100644 --- a/internal/timber/prune.go +++ b/internal/timber/prune.go @@ -73,10 +73,13 @@ func (x *pruneCommandOptions) Execute(command *cobra.Command, args []string) err if !x.prompt && (!worktree.Clean || !worktree.Merged) { continue } - if worktree.Repo != "" { - removeOptions.RepoName = worktree.Repo + // Removing a checkout can shorten other directory-derived names. + // Use the selected identity rather than resolving its name again. + repository, _, err := x.runtime.openRegisteredRepository(worktree.Repo) + if err != nil { + return err } - if err := removeOptions.removeWorktree(command, worktree.Name, true); err != nil { + if err := removeOptions.removeResolvedWorktree(command, repository, worktree, true); err != nil { return err } } diff --git a/internal/timber/prune_test.go b/internal/timber/prune_test.go index 62efb1c..03987b9 100644 --- a/internal/timber/prune_test.go +++ b/internal/timber/prune_test.go @@ -198,7 +198,7 @@ func TestPruneKeepsWorktreeWhenBranchHasNoUpstream(t *testing.T) { result := testRepository.runTimber(t, "prune", at(testRepoName, "")) testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) runGitCommand(t, testRepository.barePath, "show-ref", "--verify", "refs/heads/"+branchName) - require.ErrorContains(t, result.err, "no upstream branch") + require.NoError(t, result.err, result.stderr) } func TestPrunePromptCanForceRemoveSelectedWorktrees(t *testing.T) { diff --git a/internal/timber/remove.go b/internal/timber/remove.go index debaa58..a7fc3b3 100644 --- a/internal/timber/remove.go +++ b/internal/timber/remove.go @@ -44,7 +44,15 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string if err != nil { return err } + return x.removeResolvedWorktree(command, repository, worktree, force) +} + +func (x *removeCommandOptions) removeResolvedWorktree(command *cobra.Command, repository *Repository, worktree managedWorktree, force bool) error { name := worktree.Name + standardLayout, err := samePath(worktree.Path, x.runtime.managedWorktreePath(worktree.Repo, worktree.branchName())) + if err != nil { + return err + } if !force { if _, err := repository.git("fetch", remoteName); err != nil { @@ -52,16 +60,20 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string } } - worktree, err = x.runtime.enrichManagedWorktree(repository, worktree) - if err != nil { - return err - } - - if !force && !worktree.Clean { - return fmt.Errorf("worktree %q is not clean", name) - } - if !force && !worktree.Merged { - return fmt.Errorf("branch %q is not merged to %s", name, shortReference(worktree.UpstreamRef)) + if !force { + worktree, err = x.runtime.enrichManagedWorktree(repository, worktree) + if err != nil { + return err + } + if !worktree.Clean { + return fmt.Errorf("worktree %q is not clean", name) + } + if worktree.UpstreamRef == "" { + return fmt.Errorf("branch %q has no upstream branch; use --force to remove it", worktree.branchName()) + } + if !worktree.Merged { + return fmt.Errorf("branch %q is not merged to %s", worktree.branchName(), shortReference(worktree.UpstreamRef)) + } } currentDirectory := x.runtime.CurrentDirectory @@ -75,8 +87,10 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string if _, err := repository.git(removeArguments...); err != nil { return err } - if err := removeEmptyParents(worktree.Path, x.runtime.HomeDirectory); err != nil { - return err + if standardLayout { + if err := removeEmptyParents(worktree.Path, x.runtime.HomeDirectory); err != nil { + return err + } } branchExists, err := repository.branchStillExists(worktree.BranchReference) diff --git a/internal/timber/repo_rename_functional_test.go b/internal/timber/repo_rename_functional_test.go index 9b6ca7b..ba07379 100644 --- a/internal/timber/repo_rename_functional_test.go +++ b/internal/timber/repo_rename_functional_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestRepoRenameMovesManagedWorktreesAndPreservesUnmanagedWorktrees(t *testing.T) { +func TestRepoRenameMovesStandardWorktreesAndPreservesCustomWorktrees(t *testing.T) { t.Parallel() const ( branchName = "feature/rename/nested" @@ -69,6 +69,10 @@ func TestRepoRenameMovesManagedWorktreesAndPreservesUnmanagedWorktrees(t *testin listResult := runTimberCommandWithRuntime(t, testRepository.runtime, "list", at(newRepoName, "")) require.NoError(t, listResult.err, listResult.stderr) assert.Contains(t, listResult.stdout, branchName) + assert.Contains(t, listResult.stdout, "unmanaged") + switchResult := testRepository.runTimber(t, "switch", at(newRepoName, "unmanaged")) + require.NoError(t, switchResult.err, switchResult.stderr) + assert.Equal(t, unmanagedPath+"\n", switchResult.stdout) } func TestRepoRenameWithoutWorktrees(t *testing.T) { diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 43eeb7f..d62fad2 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -940,6 +940,15 @@ func (x Runtime) enrichManagedWorktree(repository *Repository, worktree managedW upstreamRef, err := repository.upstreamReference(worktree.branchName()) if err != nil { + // Manual branches need not track an upstream. They remain visible + // to the prune picker, but are never eligible for automatic pruning. + _, configured, configErr := repository.gitConfigValue("branch." + worktree.branchName() + ".merge") + if configErr != nil { + return managedWorktree{}, configErr + } + if !configured { + return worktree, nil + } return managedWorktree{}, err } diff --git a/internal/timber/test_helpers_test.go b/internal/timber/test_helpers_test.go index d8c33d7..e4afb70 100644 --- a/internal/timber/test_helpers_test.go +++ b/internal/timber/test_helpers_test.go @@ -36,7 +36,18 @@ type stubCreateWizardPrompter struct { } func (x stubPrompter) Prompt(input io.Reader, output io.Writer, worktrees []managedWorktree) ([]managedWorktree, error) { - return x.selected, x.err + if x.err != nil { + return nil, x.err + } + var selected []managedWorktree + for _, choice := range x.selected { + for _, worktree := range worktrees { + if worktree.Name == choice.Name && (choice.Repo == "" || worktree.Repo == choice.Repo) { + selected = append(selected, worktree) + } + } + } + return selected, nil } func (x *stubCreateWizardPrompter) Prompt( From 2e7506f881c7bc72bf2c00c6cbe93049fa18ed0a Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 6 Oct 2026 14:20:01 -0500 Subject: [PATCH 3/4] Allow 'timber create' to use an explicit checkout path Users can place a checkout where their workflow needs it without losing Timber's lifecycle commands or shell integration. Resolve relative destinations against the invocation directory and record the explicit or generated name in worktree-scoped Git metadata. Reserve visible names before creating another checkout and include custom checkouts when choosing generated names. Complete the second argument as a directory while retaining existing name completion for the first argument. --- README.md | 19 +++- internal/timber/create.go | 32 +++++- internal/timber/create_path_test.go | 107 ++++++++++++++++++ .../generate_zsh_create_functional_test.go | 40 +++++-- internal/timber/runtime.go | 20 +++- 5 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 internal/timber/create_path_test.go diff --git a/README.md b/README.md index 7f9717a..1ea9810 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,21 @@ Defaults: - bare repos: `$XDG_DATA_HOME/timber/repos/.git` (fallback: `~/.local/share/timber/repos/.git`) - worktrees: `$TIMBER_WORKTREE_ROOT///` (fallback: `~/worktrees///`) -The worktree name and branch name are identical (including `/`). +For Timber-created worktrees, the worktree name and branch name are identical (including `/`). + +Pass an optional second argument to create a worktree at an exact checkout path instead of using the shared root: + +```bash +timber create feature/login@timber /path/to/checkout +timber create feature/login@timber ../checkout +timber create @timber ../checkout # generated name +``` + +Branch-attached worktrees created directly with Git are also visible when linked to a registered repository. +Other manual worktrees use their checkout directory name; conflicts within a repository are resolved by adding parent directory components until the name is unique, reserving Timber-created names. +For example, two `checkout` directories can become `one/checkout` and `two/checkout`. +These derived names can change when conflicting worktrees are added or removed. +Detached worktrees are not listed or managed. Example: @@ -34,7 +48,7 @@ their full grouping): - worktree path: `~/worktrees/nnutter/timber/structured-names/timber` Rename with `timber repo rename ` to move between simple and -structured names; managed worktrees move with the repo. Sorting +structured names; standard-layout worktrees move with the repo. Sorting `timber list --sort repo` orders by the full structured name, so the grouping prefix controls grouping. @@ -142,6 +156,7 @@ The generated function: ```bash t repo add nnutter/timber t create feature/login@timber # then cd into it +t create feature/custom@timber ../checkout # custom location, then cd t create @timber # random name, then cd into it t switch feature/login@timber t switch feature/login # unique name across repositories diff --git a/internal/timber/create.go b/internal/timber/create.go index 111bc01..3061403 100644 --- a/internal/timber/create.go +++ b/internal/timber/create.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "github.com/spf13/cobra" ) @@ -19,9 +20,9 @@ func NewCreateCommand(runtime Runtime) *cobra.Command { options := &createCommandOptions{runtime: runtime} command := &cobra.Command{ - Use: "create [name[@repo]]", + Use: "create [name[@repo]] [path]", Short: "Create a managed Git worktree", - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(2), RunE: options.Execute, ValidArgsFunction: runtime.completeCreateArgs, } @@ -44,7 +45,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er func (x *createCommandOptions) createWorktree(command *cobra.Command, args []string) (string, error) { var raw string - if len(args) == 1 { + if len(args) > 0 { raw = args[0] } qualified, err := x.runtime.parseQualifiedName(raw) @@ -69,9 +70,25 @@ func (x *createCommandOptions) createWorktree(command *cobra.Command, args []str } worktreePath := x.runtime.managedWorktreePath(repo.Name, branchName) + if len(args) == 2 { + if args[1] == "" { + return "", errors.New("worktree path must not be empty") + } + worktreePath, err = x.runtime.absolutePath(args[1]) + if err != nil { + return "", err + } + } if err := rejectNonEmptyWorktreeDirectory(worktreePath); err != nil { return "", err } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, repo.Name) + if err != nil { + return "", err + } + if _, err := managedWorktreeByName(worktrees, branchName); err == nil { + return "", fmt.Errorf("worktree %q already exists", branchName) + } if _, err := repository.git("fetch", remoteName); err != nil { return "", fmt.Errorf("fetch %s: %w", remoteName, err) @@ -106,6 +123,15 @@ func (x *createCommandOptions) createWorktree(command *cobra.Command, args []str if err := repository.setBranchUpstream(branchName, upstreamBranch); err != nil { return "", err } + if len(args) == 2 { + gitDir, err := x.runtime.worktreeGitDir(worktreePath) + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(gitDir, worktreeNameFile), []byte(branchName+"\n"), 0o600); err != nil { + return "", fmt.Errorf("record worktree name: %w", err) + } + } if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("created "+worktreePath)); err != nil { return "", err diff --git a/internal/timber/create_path_test.go b/internal/timber/create_path_test.go new file mode 100644 index 0000000..b5dd99e --- /dev/null +++ b/internal/timber/create_path_test.go @@ -0,0 +1,107 @@ +package timber + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCustomPathPreservesExplicitNameAcrossLifecycle(t *testing.T) { + t.Parallel() + for _, relative := range []bool{false, true} { + label := "absolute" + if relative { + label = "relative" + } + t.Run(label, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "custom projects", "checkout") + argument := path + if relative { + argument = filepath.Join("custom projects", "checkout") + } + const name = "feature/custom" + created := repo.runTimber(t, "create", at(testRepoName, name), argument) + require.NoError(t, created.err, created.stderr) + assert.Equal(t, path+"\n", created.stdout) + assert.Equal(t, name, strings.TrimSpace(runGitCommand(t, path, "branch", "--show-current"))) + assert.NoDirExists(t, repo.worktreePath(name)) + + // A subsequent command has no in-memory knowledge of create. + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + require.Len(t, records, 1) + assert.Equal(t, name, records[0].Name) + assert.Equal(t, path, records[0].Path) + + duplicate := repo.runTimber(t, "create", at(testRepoName, name), filepath.Join(repo.home, "other")) + require.Error(t, duplicate.err) + assert.Contains(t, duplicate.err.Error(), "already exists") + + renamed := repo.runTimber(t, "repo", "rename", testRepoName, "renamed") + require.NoError(t, renamed.err, renamed.stderr) + switched := repo.runTimber(t, "switch", name) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path+"\n", switched.stdout) + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "feature/c"), name+"\n") + + removed := repo.runTimberFrom(t, path, "remove") + require.NoError(t, removed.err, removed.stderr) + assert.NoDirExists(t, path) + assert.DirExists(t, filepath.Dir(path)) + }) + } +} + +func TestCreateCustomPathWithGeneratedName(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "custom", "checkout") + created := repo.runTimber(t, "create", at(testRepoName, ""), path) + require.NoError(t, created.err, created.stderr) + branch := strings.TrimSpace(runGitCommand(t, path, "branch", "--show-current")) + assert.NotEmpty(t, branch) + assert.NotEqual(t, "checkout", branch) + switched := repo.runTimber(t, "switch", branch) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path+"\n", switched.stdout) +} + +func TestCreateCustomPathChecksExistingDestination(t *testing.T) { + t.Parallel() + for _, kind := range []string{"empty", "nonempty", "file", "missing"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "checkout") + switch kind { + case "empty", "nonempty": + require.NoError(t, os.Mkdir(path, 0o755)) + if kind == "nonempty" { + require.NoError(t, os.WriteFile(filepath.Join(path, "keep"), []byte("keep"), 0o600)) + } + case "file": + require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) + case "missing": + path = "" + } + result := repo.runTimber(t, "create", at(testRepoName, "custom"), path) + if kind == "empty" { + require.NoError(t, result.err, result.stderr) + assert.FileExists(t, filepath.Join(path, ".git")) + return + } + require.Error(t, result.err) + _, err := runGitCommandResult(repo.barePath, "show-ref", "--verify", "refs/heads/custom") + assert.Error(t, err) + }) + } +} diff --git a/internal/timber/generate_zsh_create_functional_test.go b/internal/timber/generate_zsh_create_functional_test.go index 27db3ab..fdff68a 100644 --- a/internal/timber/generate_zsh_create_functional_test.go +++ b/internal/timber/generate_zsh_create_functional_test.go @@ -12,16 +12,38 @@ import ( func TestFunctionalZshCreateChangesDirectoryToReportedPath(t *testing.T) { t.Parallel() - target := filepath.Join(resolvedTempDir(t), "worktree with spaces") - require.NoError(t, os.Mkdir(target, 0o755)) - command := generatedZshCommand(t, `#!/bin/sh -[ "$#" = 2 ] && [ "$1" = create ] && [ "$2" = feature@repo ] || exit 98 + for _, custom := range []bool{false, true} { + name := "default" + if custom { + name = "custom" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + target := filepath.Join(resolvedTempDir(t), "worktree with spaces") + require.NoError(t, os.Mkdir(target, 0o755)) + invocation := `source "$1"; t create feature@repo >/dev/null || exit $?; pwd -P` + if custom { + invocation = `source "$1"; t create feature@repo "$TARGET_DIR" >/dev/null || exit $?; pwd -P` + } + command := generatedZshCommand(t, `#!/bin/sh +[ "$1" = create ] && [ "$2" = feature@repo ] || exit 98 +if [ "$CUSTOM_PATH" = true ]; then + [ "$#" = 3 ] && [ "$3" = "$TARGET_DIR" ] || exit 97 +else + [ "$#" = 2 ] || exit 96 +fi printf '%s\n' "$TARGET_DIR" > "$TIMBER_CREATE_PATH_FILE" -`, `source "$1"; t create feature@repo >/dev/null || exit $?; pwd -P`) - command.Env = replaceTestEnvironment(command.Env, "TARGET_DIR="+target) +`, invocation) + customValue := "false" + if custom { + customValue = "true" + } + command.Env = replaceTestEnvironment(command.Env, "TARGET_DIR="+target, "CUSTOM_PATH="+customValue) - output, err := command.CombinedOutput() + output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) - assert.Equal(t, target, strings.TrimSpace(string(output))) + require.NoError(t, err, string(output)) + assert.Equal(t, target, strings.TrimSpace(string(output))) + }) + } } diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index d62fad2..35b2f7b 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -506,7 +506,10 @@ func (x Runtime) completeQualifiedWorktreeNames(_ *cobra.Command, args []string, } func (x Runtime) completeCreateArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { + if len(args) == 1 { + return nil, cobra.ShellCompDirectiveFilterDirs + } + if len(args) > 1 { return nil, cobra.ShellCompDirectiveNoFileComp } if name, repoPrefix, found := strings.CutLast(toComplete, "@"); found { @@ -1204,8 +1207,17 @@ func (x Runtime) worktreeDirectoryExists(repoName string, name string) (bool, er if err == nil { return true, nil } - if errors.Is(err, os.ErrNotExist) { - return false, nil + if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect worktree directory %q: %w", path, err) + } + repository, _, err := x.openRegisteredRepository(repoName) + if err != nil { + return false, err + } + worktrees, err := x.managedWorktreesFromRepository(repository, repoName) + if err != nil { + return false, err } - return false, fmt.Errorf("inspect worktree directory %q: %w", path, err) + _, err = managedWorktreeByName(worktrees, name) + return err == nil, nil } From f165615ea368ed15599ae07ba61c71f110e6a5d7 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 6 Oct 2026 15:25:28 -0500 Subject: [PATCH 4/4] Add optional worktree locations to 'timber ls' Custom-location and manual worktrees cannot be located from their names alone. Let users show checkout paths without widening the default listing. The location column stays last when PR status is also requested; JSON output already includes the path. --- README.md | 2 +- internal/timber/list.go | 15 ++++++++-- internal/timber/list_test.go | 53 ++++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1ea9810..a7d9572 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ For full flags and examples, see `timber --help` and `timber --help`. | `tui` | Interactively open an existing worktree or create a new one | | `herdr install` | Install the bundled Herdr plugin | | `herdr space` | Open a Herdr Agent + Shell space for a worktree | -| `list` | List worktrees with merge/dirtiness status and TODO.md progress (`--json`, `--pr`, `--sort`) | +| `list` | List worktrees with merge/dirtiness status and TODO.md progress (`--json`, `--pr`, `--sort`, `-L`/`--location` for checkout paths in the last column) | | `prune` | Remove clean, merged worktrees (`--dry-run`, `--prompt`) | | `remove` | Remove one worktree and delete its branch (`--force` overrides safety) | | `todo` | Open the current worktree's `TODO.md` in `$EDITOR` | diff --git a/internal/timber/list.go b/internal/timber/list.go index eaa2f58..55f74d9 100644 --- a/internal/timber/list.go +++ b/internal/timber/list.go @@ -11,6 +11,7 @@ import ( type listCommandOptions struct { repoSelection pullRequests bool + location bool jsonOutput bool sortBy listSort } @@ -27,6 +28,7 @@ func NewListCommand(runtime Runtime) *cobra.Command { ValidArgsFunction: runtime.completeRepoQualifiers, } command.Flags().BoolVar(&options.pullRequests, "pr", false, "Include open pull request status from gh") + command.Flags().BoolVarP(&options.location, "location", "L", false, "Include worktree location as the last column") command.Flags().BoolVar(&options.jsonOutput, "json", false, "Output worktrees as JSON instead of a table") command.Flags().Var(&options.sortBy, "sort", "Sort worktrees by recency (newest commit first), repo, or worktree") if err := command.RegisterFlagCompletionFunc("sort", completeListSort); err != nil { @@ -59,9 +61,15 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro if x.pullRequests { headers = append(headers, "PR") } + if x.location { + headers = append(headers, "Location") + for index := range worktrees { + worktrees[index].DisplayPath = x.runtime.displayHomePath(worktrees[index].Path) + } + } statusFormatter := newListStatusFormatter(worktrees) tableView := newOutputTable(headers...).BorderRow(true) - tableView.Rows(groupListTableRows(worktrees, statusFormatter, x.pullRequests)...) + tableView.Rows(groupListTableRows(worktrees, statusFormatter, x.pullRequests, x.location)...) _, err = fmt.Fprintln(command.OutOrStdout(), dottedListRowRules(tableView.String())) return err @@ -136,7 +144,7 @@ func dottedListRowRules(tableOutput string) string { return strings.Join(lines, "\n") } -func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusFormatter, showPullRequests bool) [][]string { +func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusFormatter, showPullRequests, showLocation bool) [][]string { rows := make([][]string, 0, (len(worktrees)+1)/2) for index, worktree := range worktrees { status := statusFormatter.format(worktree) @@ -154,6 +162,9 @@ func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusF if showPullRequests { row = append(row, worktree.PullRequest) } + if showLocation { + row = append(row, worktree.DisplayPath) + } if index%2 == 0 { rows = append(rows, row) continue diff --git a/internal/timber/list_test.go b/internal/timber/list_test.go index 4bc8533..c169f72 100644 --- a/internal/timber/list_test.go +++ b/internal/timber/list_test.go @@ -3,6 +3,7 @@ package timber import ( "encoding/json/v2" "os" + "path/filepath" "strings" "testing" @@ -45,7 +46,7 @@ func TestGroupListTableRowsAddsRuleAfterEverySecondWorktree(t *testing.T) { for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - rows := groupListTableRows(testCase.worktrees, newListStatusFormatter(testCase.worktrees), false) + rows := groupListTableRows(testCase.worktrees, newListStatusFormatter(testCase.worktrees), false, false) require.Len(t, rows, len(testCase.groupedNames)) for index, names := range testCase.groupedNames { assert.Equal(t, names, rows[index][0]) @@ -68,15 +69,61 @@ func TestGroupListTableRowsIncludesPullRequestColumnWhenEnabled(t *testing.T) { {Name: "two", Clean: true}, } - rows := groupListTableRows(worktrees, newListStatusFormatter(worktrees), true) + rows := groupListTableRows(worktrees, newListStatusFormatter(worktrees), true, false) require.Len(t, rows, 1) assert.Equal(t, "#42 ✓\n", rows[0][6]) - rows = groupListTableRows(worktrees, newListStatusFormatter(worktrees), false) + rows = groupListTableRows(worktrees, newListStatusFormatter(worktrees), false, false) require.Len(t, rows, 1) assert.Len(t, rows[0], 6) } +func TestListLocationIsOptionalLastColumn(t *testing.T) { + t.Parallel() + repository := newTestRepository(t) + const standardName = "feature/standard" + created := repository.runTimber(t, "create", at(testRepoName, standardName)) + require.NoError(t, created.err, created.stderr) + customPath := filepath.Join(resolvedTempDir(t), "checkout with spaces") + runGitCommand(t, repository.barePath, "worktree", "add", "-b", "feature/manual", customPath, "origin/main") + repository.runtime.GhExecutable = installFakeGh(t) + paths := map[string]string{ + standardName: "~/worktrees/repo/feature/standard/repo", + "checkout with spaces": customPath, + } + + without := repository.runTimber(t, "ls") + require.NoError(t, without.err, without.stderr) + assert.NotContains(t, without.stdout, "Location") + for _, path := range paths { + assert.NotContains(t, without.stdout, path) + } + + for _, flags := range [][]string{{"-L"}, {"--location"}, {"--location", "--pr"}} { + result := repository.runTimber(t, append([]string{"ls"}, flags...)...) + require.NoError(t, result.err, result.stderr) + seen := 0 + for line := range strings.SplitSeq(result.stdout, "\n") { + if !strings.HasPrefix(line, "│") { + continue + } + columns := strings.Split(strings.Trim(line, "│"), "│") + name := strings.TrimSpace(columns[0]) + if name == "Name" { + assert.Equal(t, "Location", strings.TrimSpace(columns[len(columns)-1])) + if len(flags) == 2 { + assert.Equal(t, "PR", strings.TrimSpace(columns[len(columns)-2])) + } + } + if path, found := paths[name]; found { + assert.Equal(t, path, strings.TrimSpace(columns[len(columns)-1])) + seen++ + } + } + assert.Equal(t, len(paths), seen, "flags=%v", flags) + } +} + func TestFormatDirtyStatusColorsTrueYellow(t *testing.T) { t.Parallel() assert.Equal(t, "clean", formatDirtyStatus(true))