From e2f53fee282f7c8edff6d61adc8debe058ea7c1e Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 00:59:38 -0500 Subject: [PATCH 1/4] Add Installation to README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 27e80a3..0eaf900 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ Example: - branch: `feature/login` - worktree path: `../my-repo.feature.login` +## Installation + +Install using Go, + +```bash +go install github.com/nnutter/git-wt@latest +``` + ## Commands ### `git-wt create ` From 7a2474eef0b43a78bc6f1624a7e065df40b8611c Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 20:25:46 -0500 Subject: [PATCH 2/4] Hide completion subcommand --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index d57e65e..76f81fa 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( ) func main() { + gitwt.Command.CompletionOptions.HiddenDefaultCmd = true if err := fang.Execute(context.Background(), gitwt.Command); err != nil { os.Exit(1) } From d35c9228753c60db08721915d30ef944904acb65 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 20:59:03 -0500 Subject: [PATCH 3/4] Gracefully handle a missing upstream as unmerged --- internal/gitwt/gitwt_list.go | 10 +++++-- internal/gitwt/gitwt_remove.go | 3 ++- internal/gitwt/gitwt_test.go | 48 ++++++++++++++++++++++++++++++++++ internal/gitwt/repository.go | 24 ++++++++++++----- internal/gitwt/worktree.go | 12 ++++++++- 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/internal/gitwt/gitwt_list.go b/internal/gitwt/gitwt_list.go index 0feea56..9bafc15 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", "Dirty"). + Headers("Name", "Path", "Status", "Commit", "Dirty"). Border(lipgloss.NormalBorder()). BorderHeader(true). StyleFunc(func(row int, column int) lipgloss.Style { @@ -55,7 +55,13 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro }) for _, worktree := range enrichedWorktrees { - tableView.Row(worktree.Name, worktree.DisplayPath, worktree.Status, strconv.FormatBool(!worktree.Clean)) + tableView.Row( + worktree.Name, + worktree.DisplayPath, + worktree.Status, + worktree.shortCommitHash(), + strconv.FormatBool(!worktree.Clean), + ) } _, err = fmt.Fprintln(command.OutOrStdout(), tableView.String()) diff --git a/internal/gitwt/gitwt_remove.go b/internal/gitwt/gitwt_remove.go index f5f234a..09adae9 100644 --- a/internal/gitwt/gitwt_remove.go +++ b/internal/gitwt/gitwt_remove.go @@ -83,6 +83,7 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name strin } } - _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("removed "+name)) + message := fmt.Sprintf("removed %s at %s", name, worktree.shortCommitHash()) + _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render(message)) return err } diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 9a2a65d..6f04162 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -46,6 +46,7 @@ func TestCreateListAndRemoveLifecycle(t *testing.T) { if createResult.err != nil { t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) } + branchCommitHash := strings.TrimSpace(runGitCommand(t, testRepository.mainPath, "rev-parse", "--short=7", branchName)) listResult := testRepository.runGitWT(t, "list") if listResult.err != nil { @@ -54,13 +55,20 @@ 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, branchCommitHash) { + t.Fatalf("list output missing commit hash %s: %s", branchCommitHash, listResult.stdout) + } testRepository.mergeWorktreeBranch(t, branchName) + mergedCommitHash := strings.TrimSpace(runGitCommand(t, testRepository.mainPath, "rev-parse", "--short=7", branchName)) removeResult := testRepository.runGitWT(t, "remove", branchName) if removeResult.err != nil { t.Fatalf("remove failed: %v\n%s", removeResult.err, removeResult.stderr) } + if !strings.Contains(removeResult.stderr, mergedCommitHash) { + t.Fatalf("remove output missing commit hash %s: %s", mergedCommitHash, removeResult.stderr) + } testRepository.assertBranchMissing(t, branchName) testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) @@ -198,6 +206,46 @@ func TestPruneRemovesOnlyMergedCleanWorktrees(t *testing.T) { testRepository.assertPathPresent(t, testRepository.worktreePath(unmergedBranchName)) } +func TestListSucceedsWhenUpstreamRefIsMissing(t *testing.T) { + const branchName = "feature/missing-upstream" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + + runGitCommand(t, testRepository.mainPath, "update-ref", "-d", "refs/remotes/origin/main") + + listResult := testRepository.runGitWT(t, "list") + if listResult.err != nil { + t.Fatalf("list failed: %v\n%s", listResult.err, listResult.stderr) + } + if !strings.Contains(listResult.stdout, branchName) { + t.Fatalf("list output missing worktree name: %s", listResult.stdout) + } +} + +func TestPruneKeepsWorktreeWhenUpstreamRefIsMissing(t *testing.T) { + const branchName = "feature/missing-upstream" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + + runGitCommand(t, testRepository.mainPath, "update-ref", "-d", "refs/remotes/origin/main") + + pruneResult := testRepository.runGitWT(t, "prune") + if pruneResult.err != nil { + t.Fatalf("prune failed: %v\n%s", pruneResult.err, pruneResult.stderr) + } + + testRepository.assertBranchPresent(t, branchName) + testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) +} + func TestPrunePromptCanForceRemoveSelectedWorktrees(t *testing.T) { const branchName = "feature/prompt" const workFileName = "work.txt" diff --git a/internal/gitwt/repository.go b/internal/gitwt/repository.go index a4b1e9a..bf7111e 100644 --- a/internal/gitwt/repository.go +++ b/internal/gitwt/repository.go @@ -49,7 +49,15 @@ func (x *Repository) branchExists(branchName string) (bool, error) { } func (x *Repository) branchMergedToUpstream(branchRef plumbing.ReferenceName, upstreamRef plumbing.ReferenceName) (bool, error) { - _, err := x.git("merge-base", "--is-ancestor", branchRef.String(), upstreamRef.String()) + upstreamExists, err := x.branchStillExists(upstreamRef) + if err != nil { + return false, err + } + if !upstreamExists { + return false, nil + } + + _, err = x.git("merge-base", "--is-ancestor", branchRef.String(), upstreamRef.String()) if err == nil { return true, nil } @@ -63,15 +71,17 @@ func (x *Repository) branchMergedToUpstream(branchRef plumbing.ReferenceName, up } func (x *Repository) branchStillExists(branchRef plumbing.ReferenceName) (bool, error) { - _, err := x.Reference(branchRef, true) - if errors.Is(err, plumbing.ErrReferenceNotFound) { - return false, nil + _, err := x.git("show-ref", "--verify", "--quiet", branchRef.String()) + if err == nil { + return true, nil } - if err != nil { - return false, err + + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 1 { + return false, nil } - return true, nil + return false, err } func (x Repository) git(args ...string) (gitCommandResult, error) { diff --git a/internal/gitwt/worktree.go b/internal/gitwt/worktree.go index c5b4cf4..6beb307 100644 --- a/internal/gitwt/worktree.go +++ b/internal/gitwt/worktree.go @@ -14,6 +14,7 @@ type managedWorktree struct { NormalizedName string Path string DisplayPath string + CommitHash string BranchReference plumbing.ReferenceName UpstreamRef plumbing.ReferenceName Status string @@ -22,6 +23,14 @@ type managedWorktree struct { Merged bool } +func (x managedWorktree) shortCommitHash() string { + if len(x.CommitHash) <= 7 { + return x.CommitHash + } + + return x.CommitHash[:7] +} + func enrichManagedWorktree(repository *Repository, worktree managedWorktree) (managedWorktree, error) { upstreamRef, err := repository.upstreamReference(worktree.Name) if err != nil { @@ -43,7 +52,7 @@ func enrichManagedWorktree(repository *Repository, worktree managedWorktree) (ma return managedWorktree{}, err } - merged, err := wtRepository.branchMergedToUpstream(worktree.BranchReference, upstreamRef) + merged, err := repository.branchMergedToUpstream(worktree.BranchReference, upstreamRef) if err != nil { return managedWorktree{}, err } @@ -89,6 +98,7 @@ func managedWorktreesFromRepository(repository *Repository) ([]managedWorktree, NormalizedName: normalizeWorktreeName(branchName), Path: porcelainWorktree.Path, DisplayPath: currentRelativePath(currentDirectory, porcelainWorktree.Path), + CommitHash: porcelainWorktree.CommitHash, BranchReference: plumbing.ReferenceName(porcelainWorktree.BranchRef), Main: filepath.Clean(porcelainWorktree.Path) == filepath.Clean(mainPath), }) From 41400a055d6f5377788f89caa0a095cdeaf8f2f4 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 21:25:54 -0500 Subject: [PATCH 4/4] Add completion on remove for worktree names --- internal/gitwt/gitwt_remove.go | 38 ++++++++++++++++++++++++++---- internal/gitwt/gitwt_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/internal/gitwt/gitwt_remove.go b/internal/gitwt/gitwt_remove.go index 09adae9..cecb8b7 100644 --- a/internal/gitwt/gitwt_remove.go +++ b/internal/gitwt/gitwt_remove.go @@ -3,6 +3,7 @@ package gitwt import ( "fmt" "os" + "strings" "github.com/spf13/cobra" ) @@ -15,10 +16,11 @@ func NewRemoveCommand() *cobra.Command { options := &removeCommandOptions{} command := &cobra.Command{ - Use: "remove [-f|--force] ", - Short: "Remove a managed Git worktree", - Args: cobra.ExactArgs(1), - RunE: options.Execute, + Use: "remove [-f|--force] ", + Short: "Remove a managed Git worktree", + Args: cobra.ExactArgs(1), + RunE: options.Execute, + ValidArgsFunction: completeManagedWorktreeNames, } command.Flags().BoolVarP(&options.force, "force", "f", false, "Force removal") @@ -26,6 +28,34 @@ func NewRemoveCommand() *cobra.Command { return command } +func completeManagedWorktreeNames(command *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + repository, err := PlainOpenWithOptions(".") + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + worktrees, _, err := managedWorktreesFromRepository(repository) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + worktreeNames := make([]string, 0, len(worktrees)) + for _, worktree := range worktrees { + if worktree.Main { + continue + } + if strings.HasPrefix(worktree.Name, toComplete) { + worktreeNames = append(worktreeNames, worktree.Name) + } + } + + return worktreeNames, cobra.ShellCompDirectiveNoFileComp +} + func (x *removeCommandOptions) Execute(command *cobra.Command, args []string) error { return x.removeWorktree(command, args[0], x.force) } diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 6f04162..154285c 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -180,6 +181,47 @@ func TestRemoveForceRemovesDirtyUnmergedWorktree(t *testing.T) { testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) } +func TestRemoveCompletionOffersManagedWorktreeNames(t *testing.T) { + const firstBranchName = "feature/alpha" + const secondBranchName = "feature/beta" + + testRepository := newTestRepository(t) + testRepository.runGitWT(t, "create", firstBranchName) + testRepository.runGitWT(t, "create", secondBranchName) + + currentDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("get current directory: %v", err) + } + if err := os.Chdir(testRepository.mainPath); err != nil { + t.Fatalf("change directory: %v", err) + } + defer func() { + if err := os.Chdir(currentDirectory); err != nil { + t.Fatalf("restore directory: %v", err) + } + }() + + command := NewRemoveCommand() + completions, directive := command.ValidArgsFunction(command, nil, "feature/") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected no-file completion directive, got %v", directive) + } + if !slices.Contains(completions, firstBranchName) { + t.Fatalf("missing completion for %q: %v", firstBranchName, completions) + } + if !slices.Contains(completions, secondBranchName) { + t.Fatalf("missing completion for %q: %v", secondBranchName, completions) + } + if slices.Contains(completions, "main") { + t.Fatalf("unexpected completion for main worktree: %v", completions) + } + filteredCompletions, _ := command.ValidArgsFunction(command, nil, "feature/al") + if len(filteredCompletions) != 1 || filteredCompletions[0] != firstBranchName { + t.Fatalf("expected filtered completion for %q, got %v", firstBranchName, filteredCompletions) + } +} + func TestPruneRemovesOnlyMergedCleanWorktrees(t *testing.T) { const mergedBranchName = "feature/merged" const unmergedBranchName = "feature/unmerged"