diff --git a/README.md b/README.md index b88c69a..e5c3dbf 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,39 @@ Install using Go, go install github.com/nnutter/git-wt@latest ``` +## Shell integration + +Generate a zsh function that wraps the CLI (default name `wt`): + +```bash +git-wt generate zsh +# or: git-wt generate zsh --name wt --out $XDG_DATA_HOME/zsh/site-functions --force +``` + +Ensure the output directory is on `fpath`, then restart zsh or run `compinit`. + +The generated function: + +- routes most commands to `git-wt` (`wt create`, `wt list`, `wt prune`, …) +- provides a shell-only `switch` that `cd`s into a worktree +- after a successful `wt remove`, `cd`s to the main worktree + +```bash +wt switch main +wt switch feature/login +wt create feature/login +wt remove feature/login # then cd main +wt list +``` + +If you use [carapace](https://carapace.sh), exclude its built-in `wt` completer (worktrunk) so zsh uses the generated completion instead: + +```bash +export CARAPACE_EXCLUDES=wt +``` + +Set this **before** `source <(carapace _carapace)`. You may need `carapace --clear-cache` after changing excludes. + ## Commands ### `git-wt create ` @@ -77,6 +110,8 @@ When `name` is omitted, removes the managed worktree that contains the current d It refuses to remove the main worktree, and refuses dirty or unmerged worktrees by default. Use `--force` | `-f` to force (destructive) removal. +When invoked through the shell wrapper (`wt remove`), the shell also switches to the main worktree after a successful removal. + Example: ```bash @@ -85,23 +120,25 @@ git-wt remove feature/login git-wt remove --force feature/login ``` -## Typical Flow +### `git-wt generate zsh` -Checkout [git-cd](https://github.com/nnutter/dotfiles/blob/master/bin/git-cd) to generate shell functions to easily cd to worktrees. +Generate a zsh wrapper function and completion (see [Shell integration](#shell-integration)). -Create a shell function, `nn`, to switch between the repos under my GitHub path, +## Typical Flow ```bash -git-cd --name nn --repos ~/src/github.com/nnutter +# once: install wrapper +git-wt generate zsh + +# in a repo +wt create feature/login +wt switch feature/login +# ... work ... +wt switch main +wt prune +# or: +wt remove feature/login ``` -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 -``` +For jumping between repositories under a path, you can still use something like +[git-cd](https://github.com/nnutter/dotfiles/blob/master/bin/git-cd). diff --git a/go.mod b/go.mod index 191dca8..74e0ae4 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect diff --git a/go.sum b/go.sum index 62bcd56..5e725e7 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= diff --git a/internal/gitwt/gitwt.go b/internal/gitwt/gitwt.go index 4a91b46..8846e11 100644 --- a/internal/gitwt/gitwt.go +++ b/internal/gitwt/gitwt.go @@ -18,7 +18,7 @@ func NewRootCommand() *cobra.Command { rootCommand.AddCommand(NewMigrateCommand()) rootCommand.AddCommand(NewPruneCommand()) rootCommand.AddCommand(NewRemoveCommand()) - rootCommand.AddCommand(NewShellCommand()) + rootCommand.AddCommand(NewGenerateCommand()) return rootCommand } diff --git a/internal/gitwt/gitwt_generate.go b/internal/gitwt/gitwt_generate.go new file mode 100644 index 0000000..4bccf99 --- /dev/null +++ b/internal/gitwt/gitwt_generate.go @@ -0,0 +1,27 @@ +package gitwt + +import ( + "github.com/spf13/cobra" +) + +type generateCommandOptions struct{} + +func NewGenerateCommand() *cobra.Command { + options := &generateCommandOptions{} + + command := &cobra.Command{ + Use: `generate`, + Short: `Generate shell integration wrapping git-wt`, + Args: cobra.NoArgs, + RunE: options.Execute, + } + command.CompletionOptions.HiddenDefaultCmd = true + + command.AddCommand(NewZshCommand()) + + return command +} + +func (x *generateCommandOptions) Execute(command *cobra.Command, args []string) error { + return command.Help() +} diff --git a/internal/gitwt/gitwt_generate_zsh.go b/internal/gitwt/gitwt_generate_zsh.go new file mode 100644 index 0000000..e76a078 --- /dev/null +++ b/internal/gitwt/gitwt_generate_zsh.go @@ -0,0 +1,194 @@ +package gitwt + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +type zshCommandOptions struct { + name string + out string + force bool +} + +func NewZshCommand() *cobra.Command { + options := &zshCommandOptions{} + + command := &cobra.Command{ + Use: `zsh`, + Short: `Generate zsh function and completion wrapping git-wt`, + Args: cobra.NoArgs, + RunE: options.Execute, + } + + command.Flags().StringVarP(&options.name, `name`, `n`, `wt`, `name of generated zsh function`) + command.Flags().StringVarP(&options.out, `out`, `o`, xdgDataHome()+`/zsh/site-functions`, `output directory for generated files`) + command.Flags().BoolVarP(&options.force, `force`, `f`, false, `overwrite existing generated files`) + + return command +} + +func xdgDataHome() string { + if xdg := os.Getenv(`XDG_DATA_HOME`); xdg != `` { + return xdg + } + return filepath.Join(os.Getenv(`HOME`), `.local`, `share`) +} + +func (x *zshCommandOptions) Execute(command *cobra.Command, args []string) error { + outDir := x.out + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf(`create output directory: %w`, err) + } + + functionPath := filepath.Join(outDir, x.name) + completionPath := filepath.Join(outDir, `_`+x.name) + + if !x.force { + if _, err := os.Stat(functionPath); err == nil { + return fmt.Errorf(`function file %q already exists; use --force to overwrite`, functionPath) + } + if _, err := os.Stat(completionPath); err == nil { + return fmt.Errorf(`completion file %q already exists; use --force to overwrite`, completionPath) + } + } + + if err := x.writeFunctionFile(functionPath); err != nil { + return err + } + + if err := x.writeCompletionFile(completionPath); err != nil { + return err + } + + fmt.Fprintf(command.ErrOrStderr(), "Wrote %s\n", functionPath) + fmt.Fprintf(command.ErrOrStderr(), "Wrote %s\n", completionPath) + fmt.Fprintf(command.ErrOrStderr(), "Ensure %s is on fpath. Restart zsh or run compinit if completion does not appear immediately.\n", outDir) + + return nil +} + +func (x *zshCommandOptions) writeFunctionFile(target string) error { + content := `# Generated by git-wt generate zsh +# DO NOT EDIT. Regenerate with git-wt generate zsh + +` + x.name + `() { + case "$1" in + switch) + shift + if [[ -z "$1" ]]; then + echo "Usage: ` + x.name + ` switch " >&2 + return 1 + fi + + 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 parent_dir=$(dirname "$main_dir") + local repo_name=$(basename "$main_dir") + local arg=$1 + + if [[ $arg == $repo_name.* ]]; then + arg=${arg#$repo_name.} + fi + + case "$arg" in + main) + if [[ $(pwd) == "$main_dir" ]]; then + echo "Already in main worktree" + return 0 + fi + if ! [[ -d "$main_dir" ]]; then + echo "Main worktree not found" >&2 + return 1 + fi + cd "$main_dir" + ;; + *) + local target_dir=$parent_dir/${repo_name}.${arg//\//.} + if [[ $(pwd) == "$target_dir" ]]; then + echo "Already in $arg" + return 0 + fi + if ! [[ -d "$target_dir" ]]; then + echo "Worktree $arg not found at $target_dir" >&2 + return 1 + fi + cd "$target_dir" + ;; + esac + ;; + remove) + 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 + command git-wt "$@" || return $? + cd "$main_dir" + ;; + *) + command git-wt "$@" + ;; + esac +} +` + + return os.WriteFile(target, []byte(content), 0o644) +} + +func (x *zshCommandOptions) writeCompletionFile(target string) error { + content := `#compdef ` + x.name + ` + +# Generated by git-wt generate zsh +# DO NOT EDIT. Regenerate with git-wt generate zsh + +_` + x.name + `() { + local -a subcommands + subcommands=( + 'create:Create a managed Git worktree' + 'list:List managed Git worktrees' + 'migrate:Bring existing worktrees under management' + 'prune:Remove clean merged managed worktrees' + 'remove:Remove a managed Git worktree' + 'generate:Generate shell integration' + 'switch:Switch to a worktree' + ) + + if (( CURRENT == 2 )); then + _describe 'command' subcommands + return + fi + + case $words[2] in + switch|remove) + if ! git rev-parse --is-inside-work-tree 1>/dev/null 2>/dev/null; then + return 1 + fi + + local main_dir + main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") + + local -a worktrees + if [[ $words[2] == switch ]]; then + worktrees=(main) + fi + worktrees+=($(git worktree list --porcelain 2>/dev/null | grep '^worktree ' | tail -n +2 | sed 's|^worktree '"$main_dir"'\.||')) + + _describe 'worktrees' worktrees + ;; + esac +} +` + + return os.WriteFile(target, []byte(content), 0o644) +} diff --git a/internal/gitwt/gitwt_shell.go b/internal/gitwt/gitwt_shell.go deleted file mode 100644 index 9621e8f..0000000 --- a/internal/gitwt/gitwt_shell.go +++ /dev/null @@ -1,27 +0,0 @@ -package gitwt - -import ( - "github.com/spf13/cobra" -) - -type shellCommandOptions struct{} - -func NewShellCommand() *cobra.Command { - options := &shellCommandOptions{} - - command := &cobra.Command{ - Use: `shell`, - Short: `Generate shell integration for worktrees`, - Args: cobra.NoArgs, - RunE: options.Execute, - } - command.CompletionOptions.HiddenDefaultCmd = true - - command.AddCommand(NewZshCommand()) - - return command -} - -func (x *shellCommandOptions) Execute(command *cobra.Command, args []string) error { - return command.Help() -} diff --git a/internal/gitwt/gitwt_shell_zsh.go b/internal/gitwt/gitwt_shell_zsh.go deleted file mode 100644 index 784165e..0000000 --- a/internal/gitwt/gitwt_shell_zsh.go +++ /dev/null @@ -1,150 +0,0 @@ -package gitwt - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/spf13/cobra" -) - -type zshCommandOptions struct { - name string - out string - force bool -} - -func NewZshCommand() *cobra.Command { - options := &zshCommandOptions{} - - command := &cobra.Command{ - Use: `zsh`, - Short: `Generate zsh function and completion for worktree switching`, - Args: cobra.NoArgs, - RunE: options.Execute, - } - - command.Flags().StringVarP(&options.name, `name`, `n`, `gt`, `name of generated zsh function`) - command.Flags().StringVarP(&options.out, `out`, `o`, xdgDataHome()+`/zsh/site-functions`, `output directory for generated files`) - command.Flags().BoolVarP(&options.force, `force`, `f`, false, `overwrite existing generated files`) - - return command -} - -func xdgDataHome() string { - if xdg := os.Getenv(`XDG_DATA_HOME`); xdg != `` { - return xdg - } - return filepath.Join(os.Getenv(`HOME`), `.local`, `share`) -} - -func (x *zshCommandOptions) Execute(command *cobra.Command, args []string) error { - outDir := x.out - - if err := os.MkdirAll(outDir, 0o755); err != nil { - return fmt.Errorf(`create output directory: %w`, err) - } - - functionPath := filepath.Join(outDir, x.name) - completionPath := filepath.Join(outDir, `_`+x.name) - - if !x.force { - if _, err := os.Stat(functionPath); err == nil { - return fmt.Errorf(`function file %q already exists; use --force to overwrite`, functionPath) - } - if _, err := os.Stat(completionPath); err == nil { - return fmt.Errorf(`completion file %q already exists; use --force to overwrite`, completionPath) - } - } - - if err := x.writeFunctionFile(functionPath); err != nil { - return err - } - - if err := x.writeCompletionFile(completionPath); err != nil { - return err - } - - fmt.Fprintf(command.ErrOrStderr(), "Wrote %s\n", functionPath) - fmt.Fprintf(command.ErrOrStderr(), "Wrote %s\n", completionPath) - fmt.Fprintf(command.ErrOrStderr(), "Ensure %s is on fpath. Restart zsh or run compinit if completion does not appear immediately.\n", outDir) - - return nil -} - -func (x *zshCommandOptions) writeFunctionFile(target string) error { - content := `# Generated by git-wt shell zsh -# DO NOT EDIT. Regenerate with git-wt shell zsh - -` + x.name + `() { - if [[ -z "$1" ]]; then - echo "Usage: ` + x.name + ` " - return 1 - fi - - local main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") - local parent_dir=$(dirname $main_dir) - local repo_name=$(basename $main_dir) - - local arg=$1 - - # Strip repository prefix from arg if used - if [[ $arg == $repo_name.* ]]; then - arg=${arg#$repo_name.} - fi - - case "$1" in - main) - if [[ $(pwd) == $main_dir ]]; then - echo "Already in main worktree" - return 0 - fi - if ! [[ -d $main_dir ]]; then - echo "Main worktree not found" >&2 - return 1 - fi - cd $main_dir - ;; - *) - local target_dir=$parent_dir/${repo_name}.${arg//\//.} - if [[ $(pwd) == $target_dir ]]; then - echo "Already in $arg" - return 0 - fi - if ! [[ -d $target_dir ]]; then - echo "Worktree $arg not found at $target_dir" >&2 - return 1 - fi - cd $target_dir - ;; - esac -} -` - - return os.WriteFile(target, []byte(content), 0o644) -} - -func (x *zshCommandOptions) writeCompletionFile(target string) error { - content := `#compdef ` + x.name + ` - -# Generated by git-wt shell zsh -# DO NOT EDIT. Regenerate with git-wt shell zsh - -_` + x.name + `() { - if ! git rev-parse --is-inside-work-tree 1>/dev/null 2>/dev/null; then - return 1 - fi - - local main_dir - main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") - - local -a worktrees - worktrees=(main) - worktrees+=($(git worktree list --porcelain 2>/dev/null | grep '^worktree ' | tail -n +2 | sed 's|^worktree '"$main_dir"'\.||')) - - _describe 'worktrees' worktrees -} -` - - return os.WriteFile(target, []byte(content), 0o644) -} diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 87fb53f..502330b 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -333,6 +333,82 @@ func TestRemoveCompletionOffersManagedWorktreeNames(t *testing.T) { } } +func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { + outDir := t.TempDir() + const functionName = "wt" + + result := runGitWTCommand(t, "generate", "zsh", "--name", functionName, "--out", outDir) + if result.err != nil { + t.Fatalf("generate zsh failed: %v\n%s", result.err, result.stderr) + } + + functionPath := filepath.Join(outDir, functionName) + completionPath := filepath.Join(outDir, "_"+functionName) + + functionContent, err := os.ReadFile(functionPath) + if err != nil { + t.Fatalf("read function file: %v", err) + } + completionContent, err := os.ReadFile(completionPath) + if err != nil { + t.Fatalf("read completion file: %v", err) + } + + functionText := string(functionContent) + for _, want := range []string{ + functionName + "() {", + "case \"$1\" in", + "switch)", + "remove)", + "command git-wt \"$@\"", + "cd \"$main_dir\"", + "git worktree list --porcelain", + "Usage: " + functionName + " switch ", + } { + if !strings.Contains(functionText, want) { + t.Fatalf("function missing %q:\n%s", want, functionText) + } + } + if strings.Contains(functionText, "Usage: "+functionName+" ") { + t.Fatalf("function still uses bare worktree usage:\n%s", functionText) + } + + completionText := string(completionContent) + for _, want := range []string{ + "#compdef " + functionName, + "switch:Switch to a worktree", + "remove:Remove a managed Git worktree", + "create:Create a managed Git worktree", + "case $words[2] in", + "switch|remove)", + "worktrees=(main)", + } { + if !strings.Contains(completionText, want) { + t.Fatalf("completion missing %q:\n%s", want, completionText) + } + } +} + +func TestGenerateZshRefusesOverwriteWithoutForce(t *testing.T) { + outDir := t.TempDir() + const functionName = "wt" + + first := runGitWTCommand(t, "generate", "zsh", "--name", functionName, "--out", outDir) + if first.err != nil { + t.Fatalf("first generate zsh failed: %v\n%s", first.err, first.stderr) + } + + second := runGitWTCommand(t, "generate", "zsh", "--name", functionName, "--out", outDir) + if second.err == nil { + t.Fatal("expected second generate zsh without --force to fail") + } + + forced := runGitWTCommand(t, "generate", "zsh", "--name", functionName, "--out", outDir, "--force") + if forced.err != nil { + t.Fatalf("generate zsh --force failed: %v\n%s", forced.err, forced.stderr) + } +} + func TestPruneRemovesOnlyMergedCleanWorktrees(t *testing.T) { const mergedBranchName = "feature/merged" const unmergedBranchName = "feature/unmerged" @@ -577,15 +653,6 @@ func (x testRepository) runGitWT(t *testing.T, args ...string) commandResult { func (x testRepository) runGitWTFrom(t *testing.T, directory string, args ...string) commandResult { t.Helper() - command := NewRootCommand() - command.SetArgs(args) - command.SetIn(bytes.NewBuffer(nil)) - - var stdout bytes.Buffer - var stderr bytes.Buffer - command.SetOut(&stdout) - command.SetErr(&stderr) - currentDirectory, err := os.Getwd() if err != nil { t.Fatalf("get current directory: %v", err) @@ -599,7 +666,22 @@ func (x testRepository) runGitWTFrom(t *testing.T, directory string, args ...str } }() - err = command.Execute() + return runGitWTCommand(t, args...) +} + +func runGitWTCommand(t *testing.T, args ...string) commandResult { + t.Helper() + + command := NewRootCommand() + command.SetArgs(args) + command.SetIn(bytes.NewBuffer(nil)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + command.SetOut(&stdout) + command.SetErr(&stderr) + + err := command.Execute() return commandResult{stdout: stdout.String(), stderr: stderr.String(), err: err} }