From f3dfb23b6fe7c5f0f252757d59cf6f3de19d6092 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Mon, 27 Jul 2026 12:37:26 -0500 Subject: [PATCH 1/3] Update for new worktree organizational structure (common root) --- README.md | 12 ++++++---- go.mod | 5 +++- go.sum | 1 + internal/gitwt/git_helpers.go | 2 +- internal/gitwt/gitwt_generate_zsh.go | 23 +++++++++++++++--- internal/gitwt/gitwt_prune.go | 4 ++++ internal/gitwt/gitwt_remove.go | 3 +++ internal/gitwt/gitwt_test.go | 35 ++++++++++++++++++++++++---- internal/gitwt/worktree.go | 27 ++++++++++++++------- 9 files changed, 88 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 39e10aa..4bd6d43 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,21 @@ `git-wt` manages Git worktrees using a consistent path layout. -Managed worktrees live under the main worktree at: +The main worktree is checked out in a directory named `main`. +Its parent is the root directory for every worktree, and each additional worktree uses its branch name as its relative path: -`
/.git/wt/` +`/` The branch name is used as-is (including `/`), so the worktree name and branch name are identical. Example: -- main worktree: `my-repo` +- worktree root: `my-repo` +- main worktree: `my-repo/main` - branch: `feature/login` -- worktree path: `my-repo/.git/wt/feature/login` +- worktree path: `my-repo/feature/login` -Use `git-wt migrate` to move existing worktrees (including the old sibling `repo.branch` layout) into this path. +Use `git-wt migrate` to move existing worktrees into this layout. ## Installation diff --git a/go.mod b/go.mod index 3786834..64b07df 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/samber/lo v1.53.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 ) require ( @@ -29,6 +30,7 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -44,12 +46,13 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f85ee7e..07a10f9 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,7 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gitwt/git_helpers.go b/internal/gitwt/git_helpers.go index 4e27056..a5666ab 100644 --- a/internal/gitwt/git_helpers.go +++ b/internal/gitwt/git_helpers.go @@ -41,7 +41,7 @@ func gitOutput(directory string, args ...string) (gitCommandResult, error) { } func managedWorktreePath(mainPath string, branchName string) string { - return filepath.Join(mainPath, ".git", "wt", branchName) + return filepath.Join(filepath.Dir(mainPath), branchName) } func ensureWorktreeParent(worktreePath string) error { diff --git a/internal/gitwt/gitwt_generate_zsh.go b/internal/gitwt/gitwt_generate_zsh.go index 995cb27..017e6d3 100644 --- a/internal/gitwt/gitwt_generate_zsh.go +++ b/internal/gitwt/gitwt_generate_zsh.go @@ -131,7 +131,8 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { echo "Main worktree not found" >&2 return 1 fi - local target_dir=$main_dir/.git/wt/$name + local root_dir=${main_dir:h} + local target_dir=$root_dir/$name if ! [[ -d "$target_dir" ]]; then echo "Worktree $name not found at $target_dir" >&2 return 1 @@ -167,7 +168,8 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { cd "$main_dir" ;; *) - local target_dir=$main_dir/.git/wt/$arg + local root_dir=${main_dir:h} + local target_dir=$root_dir/$arg if [[ $(pwd) == "$target_dir" ]]; then echo "Already in $arg" return 0 @@ -242,12 +244,27 @@ _` + x.name + `() { local main_dir main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") + local root_dir=${main_dir:h} local -a worktrees if [[ $words[2] == switch ]]; then worktrees=(main) fi - worktrees+=($(git worktree list --porcelain 2>/dev/null | grep '^worktree ' | tail -n +2 | sed 's|^worktree '"$main_dir"'/.git/wt/||')) + + local worktree_path="" line branch + while IFS= read -r line; do + case "$line" in + 'worktree '*) + worktree_path=${line#worktree } + ;; + 'branch refs/heads/'*) + branch=${line#branch refs/heads/} + if [[ "$worktree_path" != "$main_dir" && "$worktree_path" == "$root_dir/$branch" ]]; then + worktrees+=("$branch") + fi + ;; + esac + done < <(git worktree list --porcelain 2>/dev/null) _describe 'worktrees' worktrees ;; diff --git a/internal/gitwt/gitwt_prune.go b/internal/gitwt/gitwt_prune.go index d8ea7d1..a01910a 100644 --- a/internal/gitwt/gitwt_prune.go +++ b/internal/gitwt/gitwt_prune.go @@ -50,6 +50,10 @@ func (x *pruneCommandOptions) Execute(command *cobra.Command, args []string) err enrichedWorktrees := make([]managedWorktree, 0, len(worktrees)) for _, worktree := range worktrees { + if worktree.Main { + continue + } + enrichedWorktree, err := enrichManagedWorktree(repository, worktree) if err != nil { return err diff --git a/internal/gitwt/gitwt_remove.go b/internal/gitwt/gitwt_remove.go index fd6c596..a86565a 100644 --- a/internal/gitwt/gitwt_remove.go +++ b/internal/gitwt/gitwt_remove.go @@ -98,6 +98,9 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name strin if err != nil { return err } + if worktree.Main { + return fmt.Errorf("cannot remove main worktree") + } name = worktree.Name worktree, err = enrichManagedWorktree(repository, worktree) diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index b7ae881..c127fec 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -14,6 +14,8 @@ import ( "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type commandResult struct { @@ -49,6 +51,7 @@ func TestCreateListAndRemoveLifecycle(t *testing.T) { if createResult.err != nil { t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) } + testRepository.assertPathPresent(t, filepath.Join(testRepository.rootPath, branchName)) branchCommitHash := strings.TrimSpace(runGitCommand(t, testRepository.mainPath, "rev-parse", "--short=7", branchName)) listResult := testRepository.runGitWT(t, "list") @@ -58,6 +61,9 @@ func TestCreateListAndRemoveLifecycle(t *testing.T) { if !strings.Contains(listResult.stdout, branchName) { t.Fatalf("list output missing worktree name: %s", listResult.stdout) } + if !strings.Contains(listResult.stdout, "main") { + t.Fatalf("list output missing main worktree: %s", listResult.stdout) + } if !strings.Contains(listResult.stdout, branchCommitHash) { t.Fatalf("list output missing commit hash %s: %s", branchCommitHash, listResult.stdout) } @@ -100,6 +106,21 @@ func TestListSucceedsWithWorktreeConfig(t *testing.T) { } } +func TestListNamesMainWorktreeMainRegardlessOfBranch(t *testing.T) { + testRepository := newTestRepository(t) + runGitCommand(t, testRepository.mainPath, "checkout", "-b", "dev") + + repository, err := openRepository(testRepository.mainPath) + require.NoError(t, err) + worktrees, _, err := managedWorktreesFromRepository(repository) + require.NoError(t, err) + + mainWorktree, err := managedWorktreeForPath(worktrees, testRepository.mainPath) + require.NoError(t, err) + assert.Equal(t, "main", mainWorktree.Name) + assert.Equal(t, branchReference("dev"), mainWorktree.BranchReference) +} + func TestCreateUsesOriginHeadAsDefaultUpstream(t *testing.T) { const defaultBranch = "default" const branchName = "feature/origin-head" @@ -419,6 +440,7 @@ func TestRemoveWithNoArgsFailsFromMain(t *testing.T) { func TestRemoveFailsForMainWorktreeByName(t *testing.T) { testRepository := newTestRepository(t) + runGitCommand(t, testRepository.mainPath, "checkout", "-b", "dev") result := testRepository.runGitWT(t, "remove", "main") if result.err == nil { @@ -428,7 +450,7 @@ func TestRemoveFailsForMainWorktreeByName(t *testing.T) { t.Fatalf("expected main worktree error, got: %v", result.err) } testRepository.assertPathPresent(t, testRepository.mainPath) - testRepository.assertBranchPresent(t, "main") + testRepository.assertBranchPresent(t, "dev") } func TestRemoveWithNoArgsFailsWhenDirtyWithoutForce(t *testing.T) { @@ -591,8 +613,9 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "command git-wt \"$@\"", "cd \"$main_dir\"", "cd \"$target_dir\"", - "$main_dir/.git/wt/$name", - "$main_dir/.git/wt/$arg", + "$root_dir/$name", + "$root_dir/$arg", + "local root_dir=${main_dir:h}", "git worktree list --porcelain", "Usage: " + functionName + " switch ", } { @@ -627,7 +650,9 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "'(-u --upstream)'{-u,--upstream}'[Upstream branch]:upstream branch:'", "switch|remove)", "worktrees=(main)", - "/.git/wt/", + "worktree_path=${line#worktree }", + "branch=${line#branch refs/heads/}", + "$worktree_path\" == \"$root_dir/$branch", } { if !strings.Contains(completionText, want) { t.Fatalf("completion missing %q:\n%s", want, completionText) @@ -973,7 +998,7 @@ func newTestRepository(t *testing.T) testRepository { rootPath := t.TempDir() remotePath := filepath.Join(rootPath, "remote.git") - mainPath := filepath.Join(rootPath, "repo") + mainPath := filepath.Join(rootPath, "main") runGitCommand(t, rootPath, "init", "--bare", remotePath) runGitCommand(t, rootPath, "init", "--initial-branch=main", mainPath) diff --git a/internal/gitwt/worktree.go b/internal/gitwt/worktree.go index a591af9..2306333 100644 --- a/internal/gitwt/worktree.go +++ b/internal/gitwt/worktree.go @@ -30,22 +30,28 @@ func (x managedWorktree) shortCommitHash() string { } func enrichManagedWorktree(repository *Repository, worktree managedWorktree) (managedWorktree, error) { - upstreamRef, err := repository.upstreamReference(worktree.Name) + wtRepository, err := openRepository(worktree.Path) if err != nil { return managedWorktree{}, err } - wtRepository, err := openRepository(worktree.Path) + clean, err := wtRepository.isClean() if err != nil { return managedWorktree{}, err } - clean, err := wtRepository.isClean() + status, err := wtRepository.status() if err != nil { return managedWorktree{}, err } - status, err := wtRepository.status() + worktree.Status = status + worktree.Clean = clean + if worktree.Main { + return worktree, nil + } + + upstreamRef, err := repository.upstreamReference(worktree.Name) if err != nil { return managedWorktree{}, err } @@ -56,8 +62,6 @@ func enrichManagedWorktree(repository *Repository, worktree managedWorktree) (ma } worktree.UpstreamRef = upstreamRef - worktree.Status = status - worktree.Clean = clean worktree.Merged = merged return worktree, nil @@ -86,18 +90,23 @@ func managedWorktreesFromRepository(repository *Repository) ([]managedWorktree, continue } + isMain := filepath.Clean(porcelainWorktree.Path) == filepath.Clean(mainPath) expectedPath := managedWorktreePath(mainPath, branchName) - if filepath.Clean(expectedPath) != filepath.Clean(porcelainWorktree.Path) { + if !isMain && filepath.Clean(expectedPath) != filepath.Clean(porcelainWorktree.Path) { continue } + worktreeName := branchName + if isMain { + worktreeName = "main" + } managedWorktrees = append(managedWorktrees, managedWorktree{ - Name: branchName, + Name: worktreeName, Path: porcelainWorktree.Path, DisplayPath: currentRelativePath(currentDirectory, porcelainWorktree.Path), CommitHash: porcelainWorktree.CommitHash, BranchReference: referenceName(porcelainWorktree.BranchRef), - Main: filepath.Clean(porcelainWorktree.Path) == filepath.Clean(mainPath), + Main: isMain, }) } From 9e298e9efc90fbaca7ad120b9c20ad12435ef8cc Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Mon, 27 Jul 2026 14:56:00 -0500 Subject: [PATCH 2/3] Remove Path from `git-wt list` --- README.md | 3 +-- internal/gitwt/gitwt_list.go | 3 +-- internal/gitwt/gitwt_test.go | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4bd6d43..74c66e3 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,7 @@ List managed worktrees in a table. Columns: -- `Name`: branch name -- `Path`: relative worktree path +- `Name`: `main` for the main worktree, otherwise the branch name - `Status`: first line of `git status -sb` - `Dirty`: whether the worktree has uncommitted changes diff --git a/internal/gitwt/gitwt_list.go b/internal/gitwt/gitwt_list.go index 3e36116..9e0cb8f 100644 --- a/internal/gitwt/gitwt_list.go +++ b/internal/gitwt/gitwt_list.go @@ -44,7 +44,7 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro } tableView := table.New(). - Headers("Name", "Path", "Status", "Commit", "Dirty"). + Headers("Name", "Status", "Commit", "Dirty"). Border(lipgloss.NormalBorder()). BorderHeader(true). StyleFunc(func(row int, column int) lipgloss.Style { @@ -57,7 +57,6 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro for _, worktree := range enrichedWorktrees { tableView.Row( worktree.Name, - worktree.DisplayPath, worktree.Status, worktree.shortCommitHash(), strconv.FormatBool(!worktree.Clean), diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index c127fec..663b7c5 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -64,6 +64,9 @@ func TestCreateListAndRemoveLifecycle(t *testing.T) { if !strings.Contains(listResult.stdout, "main") { t.Fatalf("list output missing main worktree: %s", listResult.stdout) } + if strings.Contains(listResult.stdout, "Path") { + t.Fatalf("list output contains removed Path column: %s", listResult.stdout) + } if !strings.Contains(listResult.stdout, branchCommitHash) { t.Fatalf("list output missing commit hash %s: %s", branchCommitHash, listResult.stdout) } From c5e476d9459c4ae656b12d5eeeffa15c85f38b47 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Mon, 27 Jul 2026 16:38:18 -0500 Subject: [PATCH 3/3] Show branch of main worktree --- README.md | 2 +- internal/gitwt/gitwt_list.go | 10 +++++++++- internal/gitwt/gitwt_test.go | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74c66e3..94076bd 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ List managed worktrees in a table. Columns: -- `Name`: `main` for the main worktree, otherwise the branch name +- `Name`: `main ()` for the main worktree, otherwise the branch name - `Status`: first line of `git status -sb` - `Dirty`: whether the worktree has uncommitted changes diff --git a/internal/gitwt/gitwt_list.go b/internal/gitwt/gitwt_list.go index 9e0cb8f..b5b87a4 100644 --- a/internal/gitwt/gitwt_list.go +++ b/internal/gitwt/gitwt_list.go @@ -56,7 +56,7 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro for _, worktree := range enrichedWorktrees { tableView.Row( - worktree.Name, + listWorktreeName(worktree), worktree.Status, worktree.shortCommitHash(), strconv.FormatBool(!worktree.Clean), @@ -66,3 +66,11 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro _, err = fmt.Fprintln(command.OutOrStdout(), tableView.String()) return err } + +func listWorktreeName(worktree managedWorktree) string { + if !worktree.Main { + return worktree.Name + } + + return fmt.Sprintf("%s (%s)", worktree.Name, shortReference(worktree.BranchReference)) +} diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 663b7c5..6c06466 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -122,6 +122,10 @@ func TestListNamesMainWorktreeMainRegardlessOfBranch(t *testing.T) { require.NoError(t, err) assert.Equal(t, "main", mainWorktree.Name) assert.Equal(t, branchReference("dev"), mainWorktree.BranchReference) + + result := testRepository.runGitWT(t, "list") + require.NoError(t, result.err) + assert.Contains(t, result.stdout, "main (dev)") } func TestCreateUsesOriginHeadAsDefaultUpstream(t *testing.T) {