From 10f64dad57e53181c09406925580cd9b4aed9fc1 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 00:09:29 -0500 Subject: [PATCH 1/4] Refactor: Extract porcelainWorktree.branchName() --- internal/gitwt/repository.go | 8 ++++++++ internal/gitwt/worktree.go | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/gitwt/repository.go b/internal/gitwt/repository.go index 01260d9..e4f0e50 100644 --- a/internal/gitwt/repository.go +++ b/internal/gitwt/repository.go @@ -105,6 +105,14 @@ type porcelainWorktree struct { Prunable string } +func (x porcelainWorktree) branchName() string { + if !strings.HasPrefix(x.BranchRef, "refs/heads/") { + return "" + } + + return plumbing.ReferenceName(x.BranchRef).Short() +} + func (x *Repository) listPorcelainWorktrees() ([]porcelainWorktree, error) { result, err := x.git("worktree", "list", "--porcelain") if err != nil { diff --git a/internal/gitwt/worktree.go b/internal/gitwt/worktree.go index fa9b3fc..c5b4cf4 100644 --- a/internal/gitwt/worktree.go +++ b/internal/gitwt/worktree.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "sort" - "strings" "github.com/go-git/go-git/v5/plumbing" ) @@ -75,11 +74,11 @@ func managedWorktreesFromRepository(repository *Repository) ([]managedWorktree, managedWorktrees := make([]managedWorktree, 0) for _, porcelainWorktree := range porcelainWorktrees { - if porcelainWorktree.BranchRef == "" || !strings.HasPrefix(porcelainWorktree.BranchRef, "refs/heads/") { + branchName := porcelainWorktree.branchName() + if branchName == "" { continue } - branchName := plumbing.ReferenceName(porcelainWorktree.BranchRef).Short() expectedPath := managedWorktreePath(mainPath, branchName) if filepath.Clean(expectedPath) != filepath.Clean(porcelainWorktree.Path) { continue From 7c31ff59c578a1e5e35ff69231445a5e90849a55 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 00:10:47 -0500 Subject: [PATCH 2/4] Add migrate command --- internal/gitwt/gitwt.go | 1 + internal/gitwt/gitwt_migrate.go | 232 ++++++++++++++++++++++++++++++++ internal/gitwt/gitwt_test.go | 114 ++++++++++++++++ internal/gitwt/repository.go | 20 +++ 4 files changed, 367 insertions(+) create mode 100644 internal/gitwt/gitwt_migrate.go diff --git a/internal/gitwt/gitwt.go b/internal/gitwt/gitwt.go index 38cac9b..c52fdcf 100644 --- a/internal/gitwt/gitwt.go +++ b/internal/gitwt/gitwt.go @@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command { rootCommand.AddCommand(NewCreateCommand()) rootCommand.AddCommand(NewListCommand()) + rootCommand.AddCommand(NewMigrateCommand()) rootCommand.AddCommand(NewPruneCommand()) rootCommand.AddCommand(NewRemoveCommand()) diff --git a/internal/gitwt/gitwt_migrate.go b/internal/gitwt/gitwt_migrate.go new file mode 100644 index 0000000..da5a216 --- /dev/null +++ b/internal/gitwt/gitwt_migrate.go @@ -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) +} diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index ea2e7b2..9a2a65d 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -24,10 +24,19 @@ type stubPrompter struct { err error } +type stubMigratePrompter struct { + selected []migrateCandidate + err error +} + func (x stubPrompter) Prompt(input io.Reader, output io.Writer, worktrees []managedWorktree) ([]managedWorktree, error) { return x.selected, x.err } +func (x stubMigratePrompter) Prompt(input io.Reader, output io.Writer, candidates []migrateCandidate) ([]migrateCandidate, error) { + return x.selected, x.err +} + func TestCreateListAndRemoveLifecycle(t *testing.T) { const branchName = "feature/one" @@ -222,6 +231,95 @@ func TestPrunePromptCanForceRemoveSelectedWorktrees(t *testing.T) { testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) } +func TestMigrateRenamesExistingUnmanagedWorktrees(t *testing.T) { + const branchOne = "feature/alpha" + const branchTwo = "feature/beta" + + testRepository := newTestRepository(t) + legacyPathOne := filepath.Join(testRepository.rootPath, "legacy-alpha") + legacyPathTwo := filepath.Join(testRepository.rootPath, "legacy-beta") + + testRepository.createLocalBranch(t, branchOne) + testRepository.createLocalBranch(t, branchTwo) + runGitCommand(t, testRepository.mainPath, "worktree", "add", legacyPathOne, branchOne) + runGitCommand(t, testRepository.mainPath, "worktree", "add", legacyPathTwo, branchTwo) + + result := testRepository.runGitWT(t, "migrate") + if result.err != nil { + t.Fatalf("migrate failed: %v\n%s", result.err, result.stderr) + } + + testRepository.assertPathMissing(t, legacyPathOne) + testRepository.assertPathMissing(t, legacyPathTwo) + testRepository.assertPathPresent(t, testRepository.worktreePath(branchOne)) + testRepository.assertPathPresent(t, testRepository.worktreePath(branchTwo)) + assertCurrentBranchAtPath(t, testRepository.worktreePath(branchOne), branchOne) + assertCurrentBranchAtPath(t, testRepository.worktreePath(branchTwo), branchTwo) + testRepository.assertPathPresent(t, testRepository.mainPath) + assertCurrentBranchAtPath(t, testRepository.mainPath, "main") +} + +func TestMigrateCreatesWorktreesForExistingBranches(t *testing.T) { + const branchOne = "feature/alpha" + const branchTwo = "feature/beta" + + testRepository := newTestRepository(t) + testRepository.createLocalBranch(t, branchOne) + testRepository.createLocalBranch(t, branchTwo) + + result := testRepository.runGitWT(t, "migrate") + if result.err != nil { + t.Fatalf("migrate failed: %v\n%s", result.err, result.stderr) + } + + testRepository.assertPathPresent(t, testRepository.worktreePath(branchOne)) + testRepository.assertPathPresent(t, testRepository.worktreePath(branchTwo)) + assertCurrentBranchAtPath(t, testRepository.worktreePath(branchOne), branchOne) + assertCurrentBranchAtPath(t, testRepository.worktreePath(branchTwo), branchTwo) + testRepository.assertPathPresent(t, testRepository.mainPath) + assertCurrentBranchAtPath(t, testRepository.mainPath, "main") +} + +func TestMigratePromptCanSkipSelectedWorktrees(t *testing.T) { + const selectedBranch = "feature/selected" + const skippedBranch = "feature/skipped" + + testRepository := newTestRepository(t) + selectedLegacyPath := filepath.Join(testRepository.rootPath, "legacy-selected") + skippedLegacyPath := filepath.Join(testRepository.rootPath, "legacy-skipped") + + testRepository.createLocalBranch(t, selectedBranch) + testRepository.createLocalBranch(t, skippedBranch) + runGitCommand(t, testRepository.mainPath, "worktree", "add", selectedLegacyPath, selectedBranch) + runGitCommand(t, testRepository.mainPath, "worktree", "add", skippedLegacyPath, skippedBranch) + + command := &cobra.Command{} + command.SetIn(bytes.NewBuffer(nil)) + var stderr bytes.Buffer + command.SetErr(&stderr) + t.Chdir(testRepository.mainPath) + + options := &migrateCommandOptions{ + prompt: true, + prompter: stubMigratePrompter{selected: []migrateCandidate{{ + Name: selectedBranch, + CurrentPath: selectedLegacyPath, + TargetPath: testRepository.worktreePath(selectedBranch), + }}}, + } + + if err := options.Execute(command, nil); err != nil { + t.Fatalf("prompt migrate failed: %v\n%s", err, stderr.String()) + } + + testRepository.assertPathMissing(t, selectedLegacyPath) + testRepository.assertPathPresent(t, testRepository.worktreePath(selectedBranch)) + testRepository.assertPathPresent(t, skippedLegacyPath) + testRepository.assertPathMissing(t, testRepository.worktreePath(skippedBranch)) + testRepository.assertPathPresent(t, testRepository.mainPath) + assertCurrentBranchAtPath(t, testRepository.mainPath, "main") +} + type testRepository struct { rootPath string mainPath string @@ -262,6 +360,14 @@ func (x testRepository) worktreePath(branchName string) string { return managedWorktreePath(x.mainPath, branchName) } +func (x testRepository) createLocalBranch(t *testing.T, branchName string) { + t.Helper() + runGitCommand(t, x.mainPath, "branch", branchName, "main") + if _, err := os.Stat(x.worktreePath(branchName)); err == nil { + t.Fatalf("expected worktree path %s to be unused", x.worktreePath(branchName)) + } +} + func (x testRepository) runGitWT(t *testing.T, args ...string) commandResult { t.Helper() @@ -324,6 +430,14 @@ func (x testRepository) assertBranchPresent(t *testing.T, branchName string) { runGitCommand(t, x.mainPath, "show-ref", "--verify", "refs/heads/"+branchName) } +func assertCurrentBranchAtPath(t *testing.T, path string, branchName string) { + t.Helper() + currentBranch := strings.TrimSpace(runGitCommand(t, path, "branch", "--show-current")) + if currentBranch != branchName { + t.Fatalf("expected current branch at %s to be %s, not %s", path, branchName, currentBranch) + } +} + func assertCurrentBranch(t *testing.T, branchName string) { t.Helper() currentBranch := strings.TrimSpace(runGitCommand(t, "", "branch", "--show-current")) diff --git a/internal/gitwt/repository.go b/internal/gitwt/repository.go index e4f0e50..a4b1e9a 100644 --- a/internal/gitwt/repository.go +++ b/internal/gitwt/repository.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os/exec" + "sort" "strings" git "github.com/go-git/go-git/v5" @@ -150,6 +151,25 @@ func (x *Repository) listPorcelainWorktrees() ([]porcelainWorktree, error) { return worktrees, nil } +func (x *Repository) localBranches() ([]string, error) { + branchIter, err := x.Branches() + if err != nil { + return nil, fmt.Errorf("list local branches: %w", err) + } + + branches := make([]string, 0) + err = branchIter.ForEach(func(branchRef *plumbing.Reference) error { + branches = append(branches, branchRef.Name().Short()) + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterate local branches: %w", err) + } + + sort.Strings(branches) + return branches, nil +} + func (x *Repository) mainWorktreePath() (string, error) { worktrees, err := x.listPorcelainWorktrees() if err != nil { From ef822c4b07fd8d94f020dceab4c3abc186700e5d Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 00:38:10 -0500 Subject: [PATCH 3/4] Add README.md --- README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..27e80a3 --- /dev/null +++ b/README.md @@ -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: + +`.` + +Example: + +- repo: `my-repo` +- branch: `feature/login` +- worktree path: `../my-repo.feature.login` + +## Commands + +### `git-wt create ` + +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 ` + +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 +``` From 85b15d50c2ac2c99f08200b4c69ff604b16a6829 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 24 Apr 2026 00:45:12 -0500 Subject: [PATCH 4/4] Add PR validation GitHub Actions workflow --- .github/workflows/pr.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..b4a2b58 --- /dev/null +++ b/.github/workflows/pr.yml @@ -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 ./...