Skip to content
Merged

Dev #12

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
39 changes: 30 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

`git-wt` manages Git worktrees using a consistent path layout.

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:
Every managed worktree lives under a shared root, with the worktree/branch name as an intermediate directory and the repository name as the final checkout directory:

`<worktree-root>/<branch-name>`
`<worktree-root>/<worktree-name>/<repo-name>`

The branch name is used as-is (including `/`), so the worktree name and branch name are identical.
The worktree name and branch name are identical (including `/`).
The main worktree uses the name `main`.

Example:

- worktree root: `my-repo`
- main worktree: `my-repo/main`
- branch: `feature/login`
- worktree path: `my-repo/feature/login`
- worktree root: `~/src/github.com/nnutter/git-wt`
- repo name: `git-wt`
- main worktree: `~/src/github.com/nnutter/git-wt/main/git-wt`
- branch: `nn/my-feature`
- worktree path: `~/src/github.com/nnutter/git-wt/nn/my-feature/git-wt`

Use `git-wt migrate` to move existing worktrees into this layout.
Use `git-wt migrate` to move existing worktrees (including main) into this layout.

## Installation

Expand Down Expand Up @@ -45,6 +46,7 @@ The generated function:
- after a successful `wt create`, `cd`s into the new worktree unless `--no-cd`, `-r` | `--herdr`, or automatic Herdr workspace creation applies
- provides a shell-only `switch` that `cd`s into a worktree
- after a successful `wt remove`, `cd`s to the main worktree
- after a successful `wt off`, `cd`s to the collapsed worktree root

```bash
wt switch main
Expand Down Expand Up @@ -99,6 +101,7 @@ Columns:

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

- Moves the main worktree into `<root>/main/<repo-name>` when it is still at `<root>/main`.
- Creates managed worktrees for local branches that do not already have one.
- Renames existing non-managed branch worktrees into the managed path format.

Expand All @@ -111,6 +114,24 @@ git-wt migrate
git-wt migrate --prompt
```

### `git-wt off`

Tear down the managed worktree layout into a single checkout at the worktree root.

- Refuses if any managed worktree (including main) is dirty unless `--force` | `-f`.
- Removes every non-main managed worktree.
- Deletes a feature branch with `git branch -d` when it is fully merged; otherwise keeps the branch.
- Moves `<root>/main/<repo-name>` to `<root>` so the repository is a normal single checkout.

When invoked through the shell wrapper (`wt off`), the shell also `cd`s to the collapsed root after success.

Example:

```bash
git-wt off
git-wt off --force
```

### `git-wt prune`

Remove managed worktrees that are both clean, no uncommitted changes, and merged into their upstream branch.
Expand Down
36 changes: 30 additions & 6 deletions internal/gitwt/git_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,38 @@ func gitOutput(directory string, args ...string) (gitCommandResult, error) {
return result, nil
}

func managedWorktreePath(mainPath string, branchName string) string {
return filepath.Join(filepath.Dir(mainPath), branchName)
// Layout (steady state):
//
// <mainPath> = <root>/main/<repo>
// managed = <root>/<worktreeName>/<repo>
//
// Old main layout (basename mainPath == "main") is only handled by migrate.
func worktreeRoot(mainPath string) string {
return filepath.Dir(filepath.Dir(mainPath))
}

func ensureWorktreeParent(worktreePath string) error {
parent := filepath.Dir(worktreePath)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create worktree parent directory %q: %w", parent, err)
func repoName(mainPath string) string {
return filepath.Base(mainPath)
}

func managedWorktreePath(mainPath string, worktreeName string) string {
return filepath.Join(worktreeRoot(mainPath), worktreeName, repoName(mainPath))
}

func mainNeedsLayoutMigration(mainPath string) bool {
return filepath.Base(mainPath) == "main"
}

// migratedMainPath returns the nested main path when main is still on the old
// layout (<root>/main). repo name is the basename of the worktree root.
func migratedMainPath(mainPath string) string {
root := filepath.Dir(mainPath)
return filepath.Join(root, "main", filepath.Base(root))
}

func ensureWorktreeDirectory(worktreePath string) error {
if err := os.MkdirAll(worktreePath, 0o755); err != nil {
return fmt.Errorf("create worktree directory %q: %w", worktreePath, err)
}
return nil
}
Expand Down
1 change: 1 addition & 0 deletions internal/gitwt/gitwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func NewRootCommand() *cobra.Command {
rootCommand.AddCommand(NewCreateCommand())
rootCommand.AddCommand(NewListCommand())
rootCommand.AddCommand(NewMigrateCommand())
rootCommand.AddCommand(NewOffCommand())
rootCommand.AddCommand(NewPruneCommand())
rootCommand.AddCommand(NewRemoveCommand())
rootCommand.AddCommand(NewGenerateCommand())
Expand Down
2 changes: 1 addition & 1 deletion internal/gitwt/gitwt_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er
if err != nil {
return err
}
if err := ensureWorktreeParent(worktreePath); err != nil {
if err := ensureWorktreeDirectory(worktreePath); err != nil {
return err
}
if branchExists {
Expand Down
27 changes: 21 additions & 6 deletions internal/gitwt/gitwt_generate_zsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error {
echo "Main worktree not found" >&2
return 1
fi
local root_dir=${main_dir:h}
local target_dir=$root_dir/$name
local root_dir=${main_dir:h:h}
local repo_name=${main_dir:t}
local target_dir=$root_dir/$name/$repo_name
if ! [[ -d "$target_dir" ]]; then
echo "Worktree $name not found at $target_dir" >&2
return 1
Expand Down Expand Up @@ -168,8 +169,9 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error {
cd "$main_dir"
;;
*)
local root_dir=${main_dir:h}
local target_dir=$root_dir/$arg
local root_dir=${main_dir:h:h}
local repo_name=${main_dir:t}
local target_dir=$root_dir/$arg/$repo_name
if [[ $(pwd) == "$target_dir" ]]; then
echo "Already in $arg"
return 0
Expand All @@ -192,6 +194,17 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error {
command git-wt "$@" || return $?
cd "$main_dir"
;;
off)
local main_dir
main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //")
if [[ -z "$main_dir" ]]; then
echo "Main worktree not found" >&2
return 1
fi
local root_dir=${main_dir:h:h}
command git-wt "$@" || return $?
cd "$root_dir"
;;
*)
command git-wt "$@"
;;
Expand All @@ -214,6 +227,7 @@ _` + x.name + `() {
'create:Create a managed Git worktree'
'list:List managed Git worktrees'
'migrate:Bring existing worktrees under management'
'off:Tear down managed worktrees into a single checkout'
'prune:Remove clean merged managed worktrees'
'remove:Remove a managed Git worktree'
'generate:Generate shell integration'
Expand Down Expand Up @@ -244,7 +258,8 @@ _` + x.name + `() {

local main_dir
main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //")
local root_dir=${main_dir:h}
local root_dir=${main_dir:h:h}
local repo_name=${main_dir:t}

local -a worktrees
if [[ $words[2] == switch ]]; then
Expand All @@ -259,7 +274,7 @@ _` + x.name + `() {
;;
'branch refs/heads/'*)
branch=${line#branch refs/heads/}
if [[ "$worktree_path" != "$main_dir" && "$worktree_path" == "$root_dir/$branch" ]]; then
if [[ "$worktree_path" != "$main_dir" && "$worktree_path" == "$root_dir/$branch/$repo_name" ]]; then
worktrees+=("$branch")
fi
;;
Expand Down
128 changes: 118 additions & 10 deletions internal/gitwt/gitwt_migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"

"github.com/charmbracelet/huh"
"github.com/samber/lo"
Expand Down Expand Up @@ -54,6 +55,23 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e
return err
}

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

if mainNeedsLayoutMigration(mainPath) {
targetMainPath := migratedMainPath(mainPath)
if err := migrateMainWorktree(repository, command.ErrOrStderr(), mainPath, targetMainPath); err != nil {
return err
}
// Re-open from the new main path (cwd may still point at the old location).
repository, err = openRepository(targetMainPath)
if err != nil {
return err
}
}

candidates, err := migrationCandidatesFromRepository(repository)
if err != nil {
return err
Expand All @@ -72,18 +90,9 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e
}

for _, candidate := range selectedCandidates {
if err := ensureWorktreeParent(candidate.TargetPath); err != nil {
if err := applyMigrationCandidate(repository, candidate); err != nil {
return err
}
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 {
Expand All @@ -94,6 +103,105 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e
return nil
}

// migrateMainWorktree moves main from <root>/main to <root>/main/<repo> via a
// temporary sibling path (a directory cannot be moved into itself).
//
// git worktree move refuses to move the main working tree, so this uses
// filesystem renames and git worktree repair to fix linked worktree gitdirs.
func migrateMainWorktree(repository *Repository, stderr io.Writer, mainPath string, targetPath string) error {
if _, err := os.Stat(targetPath); err == nil {
return fmt.Errorf("worktree directory %q already exists", targetPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect worktree directory %q: %w", targetPath, err)
}

root := filepath.Dir(mainPath)
temporaryPath := filepath.Join(root, ".git-wt-main-migrate")
if _, err := os.Stat(temporaryPath); err == nil {
return fmt.Errorf("temporary main migration path %q already exists", temporaryPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect temporary main migration path %q: %w", temporaryPath, err)
}

if err := os.Rename(mainPath, temporaryPath); err != nil {
return fmt.Errorf("move main worktree to temporary path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create main parent directory %q: %w", filepath.Dir(targetPath), err)
}
if err := os.Rename(temporaryPath, targetPath); err != nil {
return fmt.Errorf("move main worktree to %q: %w", targetPath, err)
}

repository.WorkTree = targetPath
repository.GitDir = filepath.Join(targetPath, ".git")
if _, err := repository.git("worktree", "repair"); err != nil {
return err
}

message := fmt.Sprintf("migrated main to %s", targetPath)
_, err := fmt.Fprintf(stderr, "%s\n", statusStyle.Render(message))
return err
}

func applyMigrationCandidate(repository *Repository, candidate migrateCandidate) error {
if candidate.CurrentPath == "" {
if err := ensureWorktreeDirectory(candidate.TargetPath); err != nil {
return err
}
_, err := repository.git("worktree", "add", candidate.TargetPath, candidate.Name)
return err
}

currentPath := filepath.Clean(candidate.CurrentPath)
targetPath := filepath.Clean(candidate.TargetPath)
if pathIsWithin(currentPath, targetPath) {
return moveWorktreeViaTemporaryPath(repository, currentPath, targetPath)
}

parent := filepath.Dir(targetPath)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create worktree parent directory %q: %w", parent, err)
}
_, err := repository.git("worktree", "move", currentPath, targetPath)
return err
}

// pathIsWithin reports whether child is the same as parent or nested under it.
func pathIsWithin(parent string, child string) bool {
relativePath, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return relativePath == "." || (relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator)))
}

// moveWorktreeViaTemporaryPath moves a worktree to a path nested under its
// current location (e.g. <root>/feature -> <root>/feature/repo).
func moveWorktreeViaTemporaryPath(repository *Repository, currentPath string, targetPath string) error {
root := worktreeRoot(repository.WorkTree)
temporaryPath := filepath.Join(root, ".git-wt-migrate-"+filepath.Base(currentPath))
// Disambiguate when Base collides (nested branch names share final segment).
if filepath.Clean(temporaryPath) == currentPath || filepath.Clean(temporaryPath) == targetPath {
temporaryPath = filepath.Join(root, ".git-wt-migrate-tmp")
}
if _, err := os.Stat(temporaryPath); err == nil {
return fmt.Errorf("temporary migration path %q already exists", temporaryPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect temporary migration path %q: %w", temporaryPath, err)
}

if _, err := repository.git("worktree", "move", currentPath, temporaryPath); err != nil {
return err
}
parent := filepath.Dir(targetPath)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create worktree parent directory %q: %w", parent, err)
}
_, err := repository.git("worktree", "move", temporaryPath, targetPath)
return err
}

func (huhMigratePrompter) Prompt(input io.Reader, output io.Writer, candidates []migrateCandidate) ([]migrateCandidate, error) {
selectedNames := make([]string, 0, len(candidates))
options := lo.Map(candidates, func(candidate migrateCandidate, _ int) huh.Option[string] {
Expand Down
Loading