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
26 changes: 26 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: PR

on:
pull_request:

jobs:
validation:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Run tests
run: go test ./...

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

- name: Run govulncheck
run: govulncheck ./...
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# git-wt

`git-wt` manages Git worktrees using a consistent naming convention.

Managed worktrees are stored next to the main repository using this path format:

`<repo>.<normalized-branch-name>`

Example:

- repo: `my-repo`
- branch: `feature/login`
- worktree path: `../my-repo.feature.login`

## Commands

### `git-wt create <name>`

Create a managed worktree for a branch.

- If the branch already exists, the worktree is created from that branch.
- If the branch does not exist, it is created from the upstream branch; which defaults to the default origin branch but can be set explicity with `--upstream` | `-u`.

Example:

```bash
git-wt create feature/login
git-wt create -u origin/v1.2 hotfix/1.2.1
```

### `git-wt list`

List managed worktrees in a table.

Columns:

- `Name`: branch name
- `Path`: relative worktree path
- `Status`: first line of `git status -sb`
- `Dirty`: whether the worktree has uncommitted changes

### `git-wt migrate`

Bring existing branch worktrees under `git-wt` management.

- Creates managed worktrees for local branches that do not already have one.
- Renames existing non-managed branch worktrees into the managed path format.

Use `--prompt` | `-p` to review the proposed migrations before applying them.

Example:

```bash
git-wt migrate
git-wt migrate --prompt
```

### `git-wt prune`

Remove managed worktrees that are both clean, no uncommitted changes, and merged into their upstream branch.

Use `--prompt` | `-p` to choose which worktrees to prune interactively.

### `git-wt remove <name>`

Remove a managed worktree and delete its branch.

It refuses to remove dirty or unmerged worktrees by default.
Use `--force` | `-f` to force (destructive) removal.

Example:

```bash
git-wt remove feature/login
git-wt remove --force feature/login
```

## Typical Flow

Checkout [git-cd](https://github.com/nnutter/dotfiles/blob/master/bin/git-cd) to generate shell functions to easily cd to worktrees.

Create a shell function, `nn`, to switch between the repos under my GitHub path,

```bash
git-cd --name nn --repos ~/src/github.com/nnutter
```

Then a typical flow might look like,

```bash
nn some-repo
git-wt create feature/login
...
nn some-repo.feature.login
nn some-repo
git-wt prune
```
1 change: 1 addition & 0 deletions internal/gitwt/gitwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {

rootCommand.AddCommand(NewCreateCommand())
rootCommand.AddCommand(NewListCommand())
rootCommand.AddCommand(NewMigrateCommand())
rootCommand.AddCommand(NewPruneCommand())
rootCommand.AddCommand(NewRemoveCommand())

Expand Down
232 changes: 232 additions & 0 deletions internal/gitwt/gitwt_migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package gitwt

import (
"fmt"
"io"
"os"
"path/filepath"
"sort"

"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)

type migrateCandidate struct {
Action string
Name string
CurrentPath string
TargetPath string
DisplayCurrentPath string
DisplayTargetPath string
}

type migratePrompter interface {
Prompt(io.Reader, io.Writer, []migrateCandidate) ([]migrateCandidate, error)
}

type migrateCommandOptions struct {
prompt bool
prompter migratePrompter
}

type huhMigratePrompter struct{}

func NewMigrateCommand() *cobra.Command {
options := &migrateCommandOptions{prompter: huhMigratePrompter{}}

command := &cobra.Command{
Use: "migrate",
Short: "Migrate existing Git worktrees to managed paths",
Args: cobra.NoArgs,
RunE: options.Execute,
}

command.Flags().BoolVarP(&options.prompt, "prompt", "p", false, "Prompt before migrating")

return command
}

func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) error {
repository, err := PlainOpenWithOptions(".")
if err != nil {
return err
}

candidates, err := migrationCandidatesFromRepository(repository)
if err != nil {
return err
}

selectedCandidates := candidates
if x.prompt {
selectedCandidates, err = x.prompter.Prompt(command.InOrStdin(), command.ErrOrStderr(), candidates)
if err != nil {
return err
}
}

if err := validateMigrationCandidates(selectedCandidates); err != nil {
return err
}

for _, candidate := range selectedCandidates {
if candidate.CurrentPath == "" {
if _, err := repository.git("worktree", "add", candidate.TargetPath, candidate.Name); err != nil {
return err
}
} else {
if _, err := repository.git("worktree", "move", candidate.CurrentPath, candidate.TargetPath); err != nil {
return err
}
}

message := fmt.Sprintf("%sd %s to %s", candidate.Action, candidate.Name, candidate.TargetPath)
if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render(message)); err != nil {
return err
}
}

return nil
}

func (huhMigratePrompter) Prompt(input io.Reader, output io.Writer, candidates []migrateCandidate) ([]migrateCandidate, error) {
selectedNames := make([]string, 0, len(candidates))
options := make([]huh.Option[string], 0, len(candidates))
for _, candidate := range candidates {
label := candidate.Name + " ("
if candidate.CurrentPath == "" {
label += "create " + candidate.DisplayTargetPath
} else {
label += candidate.DisplayCurrentPath + " -> " + candidate.DisplayTargetPath
}
label += ")"
options = append(options, huh.NewOption(label, candidate.Name).Selected(true))
}

form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select worktrees to migrate").
Options(options...).
Value(&selectedNames),
),
).WithInput(input).WithOutput(output)

if err := form.Run(); err != nil {
return nil, err
}

selectedCandidates := make([]migrateCandidate, 0, len(selectedNames))
for _, selectedName := range selectedNames {
candidate, err := migrateCandidateByName(candidates, selectedName)
if err != nil {
return nil, err
}
selectedCandidates = append(selectedCandidates, candidate)
}

return selectedCandidates, nil
}

func migrationCandidatesFromRepository(repository *Repository) ([]migrateCandidate, error) {
porcelainWorktrees, err := repository.listPorcelainWorktrees()
if err != nil {
return nil, err
}

mainPath, err := repository.mainWorktreePath()
if err != nil {
return nil, err
}

currentDirectory, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("get current directory: %w", err)
}

branchesByWorktree := make(map[string]string, len(porcelainWorktrees))
candidates := make([]migrateCandidate, 0)
for _, porcelainWorktree := range porcelainWorktrees {
if porcelainWorktree.BranchRef == "" {
continue
}

branchName := porcelainWorktree.branchName()
if branchName == "" {
continue
}

branchesByWorktree[branchName] = porcelainWorktree.Path
if filepath.Clean(porcelainWorktree.Path) == filepath.Clean(mainPath) {
continue
}

targetPath := managedWorktreePath(mainPath, branchName)
if filepath.Clean(porcelainWorktree.Path) == filepath.Clean(targetPath) {
continue
}

candidates = append(candidates, migrateCandidate{
Action: "migrate",
Name: branchName,
CurrentPath: porcelainWorktree.Path,
TargetPath: targetPath,
DisplayCurrentPath: currentRelativePath(currentDirectory, porcelainWorktree.Path),
DisplayTargetPath: currentRelativePath(currentDirectory, targetPath),
})
}

branches, err := repository.localBranches()
if err != nil {
return nil, err
}

for _, branchName := range branches {
if _, ok := branchesByWorktree[branchName]; ok {
continue
}

targetPath := managedWorktreePath(mainPath, branchName)
candidates = append(candidates, migrateCandidate{
Action: "create",
Name: branchName,
TargetPath: targetPath,
DisplayTargetPath: currentRelativePath(currentDirectory, targetPath),
})
}

sort.Slice(candidates, func(leftIndex int, rightIndex int) bool {
return candidates[leftIndex].Name < candidates[rightIndex].Name
})

return candidates, nil
}

func validateMigrationCandidates(candidates []migrateCandidate) error {
targetPaths := make(map[string]string, len(candidates))
for _, candidate := range candidates {
targetPath := filepath.Clean(candidate.TargetPath)
if existingName, ok := targetPaths[targetPath]; ok {
return fmt.Errorf("worktrees %q and %q share target path %q", existingName, candidate.Name, candidate.TargetPath)
}
targetPaths[targetPath] = candidate.Name

if _, err := os.Stat(candidate.TargetPath); err == nil {
return fmt.Errorf("worktree directory %q already exists", candidate.TargetPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect worktree directory %q: %w", candidate.TargetPath, err)
}
}

return nil
}

func migrateCandidateByName(candidates []migrateCandidate, name string) (migrateCandidate, error) {
for _, candidate := range candidates {
if candidate.Name == name {
return candidate, nil
}
}

return migrateCandidate{}, fmt.Errorf("unknown worktree %q", name)
}
Loading