Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`
Expand Down
10 changes: 8 additions & 2 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", "Dirty").
Headers("Name", "Path", "Status", "Commit", "Dirty").
Border(lipgloss.NormalBorder()).
BorderHeader(true).
StyleFunc(func(row int, column int) lipgloss.Style {
Expand All @@ -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())
Expand Down
41 changes: 36 additions & 5 deletions internal/gitwt/gitwt_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitwt
import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
)
Expand All @@ -15,17 +16,46 @@ func NewRemoveCommand() *cobra.Command {
options := &removeCommandOptions{}

command := &cobra.Command{
Use: "remove [-f|--force] <name>",
Short: "Remove a managed Git worktree",
Args: cobra.ExactArgs(1),
RunE: options.Execute,
Use: "remove [-f|--force] <name>",
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")

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)
}
Expand Down Expand Up @@ -83,6 +113,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
}
90 changes: 90 additions & 0 deletions internal/gitwt/gitwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -46,6 +47,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 {
Expand All @@ -54,13 +56,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))
Expand Down Expand Up @@ -172,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"
Expand All @@ -198,6 +248,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"
Expand Down
24 changes: 17 additions & 7 deletions internal/gitwt/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
12 changes: 11 additions & 1 deletion internal/gitwt/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type managedWorktree struct {
NormalizedName string
Path string
DisplayPath string
CommitHash string
BranchReference plumbing.ReferenceName
UpstreamRef plumbing.ReferenceName
Status string
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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),
})
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down