Skip to content
Merged

Dev #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

`<main>/.git/wt/<branch-name>`
`<worktree-root>/<branch-name>`

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

Expand Down Expand Up @@ -89,8 +91,7 @@ List managed worktrees in a table.

Columns:

- `Name`: branch name
- `Path`: relative worktree path
- `Name`: `main (<branch>)` for the main worktree, otherwise the branch name
- `Status`: first line of `git status -sb`
- `Dirty`: whether the worktree has uncommitted changes

Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand All @@ -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
)
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 1 addition & 1 deletion internal/gitwt/git_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 20 additions & 3 deletions internal/gitwt/gitwt_generate_zsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
;;
Expand Down
13 changes: 10 additions & 3 deletions internal/gitwt/gitwt_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -56,8 +56,7 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro

for _, worktree := range enrichedWorktrees {
tableView.Row(
worktree.Name,
worktree.DisplayPath,
listWorktreeName(worktree),
worktree.Status,
worktree.shortCommitHash(),
strconv.FormatBool(!worktree.Clean),
Expand All @@ -67,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))
}
4 changes: 4 additions & 0 deletions internal/gitwt/gitwt_prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/gitwt/gitwt_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 37 additions & 5 deletions internal/gitwt/gitwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -58,6 +61,12 @@ 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, "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)
}
Expand Down Expand Up @@ -100,6 +109,25 @@ 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)

result := testRepository.runGitWT(t, "list")
require.NoError(t, result.err)
assert.Contains(t, result.stdout, "main (dev)")
}

func TestCreateUsesOriginHeadAsDefaultUpstream(t *testing.T) {
const defaultBranch = "default"
const branchName = "feature/origin-head"
Expand Down Expand Up @@ -419,6 +447,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 {
Expand All @@ -428,7 +457,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) {
Expand Down Expand Up @@ -591,8 +620,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 <worktree>",
} {
Expand Down Expand Up @@ -627,7 +657,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)
Expand Down Expand Up @@ -973,7 +1005,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)
Expand Down
27 changes: 18 additions & 9 deletions internal/gitwt/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down