diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 74da96b..54d9bab 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,12 +6,13 @@ FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} ARG NODE_VERSION="none" RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi -# [Optional] Uncomment this section to install additional OS packages. -# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ -# && apt-get -y install --no-install-recommends +# Install additional OS packages. +# hadolint ignore=DL3009 +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends fish # [Optional] Uncomment this line to install additional gems. # RUN gem install # [Optional] Uncomment this line to install global node packages. -# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index dd0cc98..51c8c1a 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -42,8 +42,11 @@ jobs: bundler-cache: true - name: Build binary run: rake build - - name: Install ZSH on ubuntu + - name: Install shells on ubuntu if: matrix.os == 'ubuntu-latest' - run: sudo apt-get -y install zsh + run: sudo apt-get -y install zsh fish + - name: Install shells on macos + if: matrix.os == 'macos-latest' + run: brew install fish - name: Run integration Tests run: bundle exec cucumber -s --tags="not @wip" --color diff --git a/README.md b/README.md index 01999e9..d86a0f6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ To initialize shell functions, add the following to your `~/.bash_profile` or eval "$(scmpuff init -s)" +or for Fish, add the following to your `~/.config/fish/config.fish` file: + + scmpuff init -s --shell=fish | source + This will define the scmpuff shell functions as well as some handy shortcuts. diff --git a/Rakefile b/Rakefile index 73b4301..c3945fd 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,11 @@ end desc "builds & installs the binary to $GOPATH/bin" task :install => :build do - cp "bin/scmpuff", "#{ENV['GOPATH']}/bin/scmpuff" + # Don't cp directly over an existing file - it causes problems with Apple code signing. + # https://developer.apple.com/documentation/security/updating_mac_software + destination = "#{ENV['GOPATH']}/bin/scmpuff" + rm destination if File.exist?(destination) + cp "bin/scmpuff", destination end desc "run unit tests" diff --git a/commands/inits/data/git_wrapper.fish b/commands/inits/data/git_wrapper.fish new file mode 100644 index 0000000..5b1436e --- /dev/null +++ b/commands/inits/data/git_wrapper.fish @@ -0,0 +1,25 @@ +# Based on https://github.com/arbelt/fish-plugin-scmpuff, +# with scmpuff-exec support (https://github.com/mroth/scmpuff/pull/49) +functions -e git + +set -q SCMPUFF_GIT_CMD; or set -x SCMPUFF_GIT_CMD (which git) + +function git + if test (count $argv) -eq 0 + eval $SCMPUFF_GIT_CMD + set -l s $status + return $s + end + + switch $argv[1] + case commit blame log rebase merge + scmpuff exec -- "$SCMPUFF_GIT_CMD" $argv + case checkout diff rm reset restore + scmpuff exec --relative -- "$SCMPUFF_GIT_CMD" $argv + case add + scmpuff exec -- "$SCMPUFF_GIT_CMD" $argv + scmpuff_status + case '*' + eval command "$SCMPUFF_GIT_CMD" (string escape -- $argv) + end +end diff --git a/commands/inits/data/status_shortcuts.fish b/commands/inits/data/status_shortcuts.fish new file mode 100644 index 0000000..0c9a2cb --- /dev/null +++ b/commands/inits/data/status_shortcuts.fish @@ -0,0 +1,32 @@ +# Based on https://github.com/arbelt/fish-plugin-scmpuff, +# with fish3 fix https://github.com/arbelt/fish-plugin-scmpuff/pull/3 +function scmpuff_status + scmpuff_clear_vars + set -lx scmpuff_env_char "e" + set -l cmd_output (/usr/bin/env scmpuff status --filelist $argv) + set -l es "$status" + + if test $es -ne 0 + return $es + end + + set -l files (string split \t $cmd_output[1]) + if test (count $files) -gt 0 + for e in (seq (count $files)) + set -gx "$scmpuff_env_char""$e" "$files[$e]" + end + end + + for line in $cmd_output[2..-1] + echo $line + end +end + +function scmpuff_clear_vars + set -l scmpuff_env_char "e" + set -l scmpuff_env_vars (set -x | awk '{print $1}' | grep -E '^'$scmpuff_env_char'[0-9]+') + + for v in $scmpuff_env_vars + set -e $v + end +end diff --git a/commands/inits/init.go b/commands/inits/init.go index 78c8f5c..5aaca0f 100644 --- a/commands/inits/init.go +++ b/commands/inits/init.go @@ -2,50 +2,80 @@ package inits import ( "fmt" + "os" + "path/filepath" + "strings" "github.com/spf13/cobra" ) -// Since the flags are defined and used in different locations, we need to -// define a variable outside with the correct scope to assign the flag to work -// with. -var includeAliases bool -var outputScript bool -var wrapGit bool - // CommandInit generates the command handler for `scmpuff init` func CommandInit() *cobra.Command { + var ( + shellType string + includeAliases bool + wrapGit bool + legacyShow bool + ) var InitCmd = &cobra.Command{ Use: "init", Short: "Output initialization script", Long: ` -Outputs the bash/zsh initialization script for scmpuff. +Outputs the shell initialization script for scmpuff. + +Initialize scmpuff by adding the following to your ~/.bash_profile or ~/.zshrc: + + eval "$(scmpuff init --shell=sh)" -This should probably be evaluated in your shell startup. +For fish shell, add the following to ~/.config/fish/config.fish instead: + + scmpuff init --shell=fish | source + +There are a number of flags to customize the shell integration. `, Run: func(cmd *cobra.Command, args []string) { - if outputScript { - printScript() - } else { - fmt.Println(helpString()) + // If someone's using the old ---show flag, opt-in to the newer --shell defaults + if legacyShow { + shellType = defaultShellType() + } + + switch strings.ToLower(shellType) { + case "": + cmd.Help() + os.Exit(0) + + case "sh", "bash", "zsh": + fmt.Println(bashCollection.Output(wrapGit, includeAliases)) + os.Exit(0) + + case "fish": + fmt.Println(fishCollection.Output(wrapGit, includeAliases)) + os.Exit(0) + + default: + fmt.Fprintf(os.Stderr, "Unrecognized shell '%s'\n", shellType) + os.Exit(1) } }, + // Watch out for accidental args caused by NoOptDefVal (https://github.com/spf13/cobra/issues/866) + Args: cobra.NoArgs, } // --aliases InitCmd.Flags().BoolVarP( &includeAliases, "aliases", "a", true, - "Include short aliases for convenience", + "Include short git aliases", ) - // --show - InitCmd.Flags().BoolVarP( - &outputScript, - "show", "s", false, + // --show (deprecated in favor of --shell) + InitCmd.Flags().BoolVar( + &legacyShow, + "show", false, "Output scmpuff initialization scripts", ) + InitCmd.Flags().MarkHidden("show") // --wrap InitCmd.Flags().BoolVarP( @@ -54,12 +84,27 @@ This should probably be evaluated in your shell startup. "Wrap standard git commands", ) + // --shell + InitCmd.Flags().StringVarP( + &shellType, + "shell", "s", "", + "Output shell type: sh | bash | zsh | fish", + ) + InitCmd.Flag("shell").NoOptDefVal = defaultShellType() + return InitCmd } -// TODO: check for proper shell version -func helpString() string { - return `# Initialize scmpuff by adding the following to ~/.bash_profile or ~/.zshrc: +// defaultShell returns the shellType assumed if user does not specify. +// in the future, we may wish to customize this based on the $SHELL variable. +func defaultShellType() string { + if shellenv, ok := os.LookupEnv("SHELL"); ok { + base := filepath.Base(shellenv) + switch base { + case "sh", "bash", "zsh", "fish": + return base + } + } -eval "$(scmpuff init -s)"` + return "sh" } diff --git a/commands/inits/init_test.go b/commands/inits/init_test.go new file mode 100644 index 0000000..029db41 --- /dev/null +++ b/commands/inits/init_test.go @@ -0,0 +1,27 @@ +package inits + +import "testing" + +func Test_defaultShellType(t *testing.T) { + tests := []struct { + shellenv string + want string + }{ + // supported shells at a bunch of different locations + {"/bin/zsh", "zsh"}, + {"/usr/bin/zsh", "zsh"}, + {"/usr/local/bin/zsh", "zsh"}, + {"/bin/bash", "bash"}, + {"/usr/local/bin/fish", "fish"}, + + // edge cases + {"", "sh"}, + {"/bin/unsupported", "sh"}, + } + for _, tt := range tests { + t.Setenv("SHELL", tt.shellenv) + if got := defaultShellType(); got != tt.want { + t.Errorf("defaultShellType(%v) = %v, want %v", tt.shellenv, got, tt.want) + } + } +} diff --git a/commands/inits/scripts.go b/commands/inits/scripts.go index ee20f80..6787679 100644 --- a/commands/inits/scripts.go +++ b/commands/inits/scripts.go @@ -2,28 +2,52 @@ package inits import ( _ "embed" - "fmt" + "strings" ) //go:embed data/status_shortcuts.sh var scriptStatusShortcuts string +//go:embed data/status_shortcuts.fish +var scriptStatusShortcutsFish string + //go:embed data/aliases.sh var scriptAliases string //go:embed data/git_wrapper.sh var scriptGitWrapper string -func printScript() { - if outputScript { - fmt.Println(scriptStatusShortcuts) - } +//go:embed data/git_wrapper.fish +var scriptGitWrapperFish string - if includeAliases { - fmt.Println(scriptAliases) - } +type scriptCollection struct { + statusShortcuts string + gitWrapper string + aliases string +} +var bashCollection = scriptCollection{ + statusShortcuts: scriptStatusShortcuts, + gitWrapper: scriptGitWrapper, + aliases: scriptAliases, +} + +var fishCollection = scriptCollection{ + statusShortcuts: scriptStatusShortcutsFish, + gitWrapper: scriptGitWrapperFish, + aliases: scriptAliases, +} + +func (sc scriptCollection) Output(wrapGit, aliases bool) string { + var b strings.Builder + b.WriteString(sc.statusShortcuts) if wrapGit { - fmt.Println(scriptGitWrapper) + b.WriteRune('\n') + b.WriteString(sc.gitWrapper) + } + if aliases { + b.WriteRune('\n') + b.WriteString(sc.aliases) } + return b.String() } diff --git a/features/command_init.feature b/features/command_init.feature index f12e024..bb299f2 100644 --- a/features/command_init.feature +++ b/features/command_init.feature @@ -7,6 +7,11 @@ Feature: init command When I successfully run `scmpuff init -s` Then the output should contain "scmpuff_status()" + Scenario: init with an unrecognized shell should produce an error + When I run `scmpuff init --shell=oil` + Then the exit status should be 1 + Then the output should contain "Unrecognized shell 'oil'" + Scenario Outline: --aliases controls short aliases in output (default: yes) When I successfully run `scmpuff init ` Then the output contain "alias gs='scmpuff_status'" @@ -32,12 +37,14 @@ Feature: init command Scenario Outline: Evaling init -s defines status shortcuts in environment When I run `` interactively - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "type scmpuff_status" And I type "type scmpuff_clear_vars" - And I type "exit" + And I close the shell `` Then the output should not contain "not found" Examples: | shell | | bash | | zsh | + | fish | + diff --git a/features/shell_functions.feature b/features/shell_functions.feature index 929cb4d..85b0fa0 100644 --- a/features/shell_functions.feature +++ b/features/shell_functions.feature @@ -17,11 +17,11 @@ Feature: scmpuff_status function non-zero exit codes from the underlying process are preserved. When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" - And I type "exit $?" + And I close the shell `` Then the exit status should be 128 - And the output should contain: + Then the stderr should contain: """ Not a git repository (or any of the parent directories) """ @@ -29,29 +29,30 @@ Feature: scmpuff_status function | shell | | bash | | zsh | + | fish | Scenario Outline: Basic functionality works with shell wrapper. Given I am in a git repository When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" - And I type "exit $?" + And I close the shell `` Then the exit status should be 0 And the output should contain "No changes (working directory clean)" Examples: | shell | | bash | | zsh | + | fish | Scenario Outline: Sets proper environment variables in shell Given I am in a complex working tree status matching scm_breeze tests And the scmpuff environment variables have been cleared When I run `` interactively - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type `echo -e "e1:$e1\ne2:$e2\ne3:$e3\ne4:$e4\ne5:$e5\n"` - And I type "exit" - And I stop the command "" + And I close the shell `` Then the output should match /^e1:.*new_file$/ And the output should match /^e2:.*deleted_file$/ And the output should match /^e3:.*new_file$/ @@ -61,6 +62,7 @@ Feature: scmpuff_status function | shell | | bash | | zsh | + | fish | Scenario Outline: Sets proper environment variables in shell with weird filenames Given I am in a git repository @@ -68,11 +70,10 @@ Feature: scmpuff_status function And an empty file named "bb|cc" And an empty file named "cc*dd" When I run `` interactively - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type `echo -e "e1:$e1\ne2:$e2\ne3:$e3\ne4:$e4\n"` - And I type "exit" - And I stop the command "" + And I close the shell `` Then the output should match /^e1:.*aa bb$/ And the output should match /^e2:.*bb\|cc$/ And the output should match /^e3:.*cc\*dd$/ @@ -81,18 +82,19 @@ Feature: scmpuff_status function | shell | | bash | | zsh | + | fish | Scenario Outline: Clears extra environment variables from before Given I am in a complex working tree status matching scm_breeze tests And the scmpuff environment variables have been cleared When I run `` interactively - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type "git add new_file" And I type "git commit -m 'so be it'" And I type "scmpuff_status" And I type `echo -e "e1:$e1\ne2:$e2\ne3:$e3\ne4:$e4\ne5:$e5\n"` - And I type "exit" + And I close the shell `` Then the output should match /^e1:.*deleted_file$/ And the output should match /^e2:.*untracked_file$/ And the output should match /^e3:$/ @@ -102,43 +104,44 @@ Feature: scmpuff_status function | shell | | bash | | zsh | + | fish | Scenario Outline: default SCMPUFF_GIT_CMD is set to absolute path of a git command When I run `` interactively - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "echo $SCMPUFF_GIT_CMD" - And I type "exit" - And I stop the command "" + And I close the shell `` Then the output should match %r<^/.+/git$> # ^^ is absolute path to git: begins with a /, and ends with /git Examples: | shell | | bash | | zsh | + | fish | Scenario Outline: SCMPUFF_GIT_CMD is set to absolute path of a git command, eliminating aliases When I run `` interactively And I type "alias git=/foo/bar" - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "echo $SCMPUFF_GIT_CMD" - And I type "exit" - And I stop the command "" + And I close the shell `` Then the output should match %r<^/.+/git$> # ^^ is absolute path to git: begins with a /, and ends with /git Examples: | shell | | bash | | zsh | + | fish | Scenario Outline: SCMPUFF_GIT_CMD respects existing environment variables When I run `` interactively And I type "export SCMPUFF_GIT_CMD=/foo/hub" - And I type `eval "$(scmpuff init -s)"` + And I initialize scmpuff in `` And I type "echo $SCMPUFF_GIT_CMD" - And I type "exit" - And I stop the command "" + And I close the shell `` Then the output should contain exactly "/foo/hub" Examples: | shell | | bash | | zsh | + | fish | diff --git a/features/shell_wrappers.feature b/features/shell_wrappers.feature index d493650..f6fa30e 100644 --- a/features/shell_wrappers.feature +++ b/features/shell_wrappers.feature @@ -10,10 +10,10 @@ Feature: optional wrapping of normal git cmds in the shell And a 4 byte file named "foo.bar" And a 4 byte file named "bar.foo" When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type "git add 1" - And I type "exit" + And I close the shell `` Then the output should contain: """ # On branch: master | [*] => $e* @@ -31,22 +31,24 @@ Feature: optional wrapping of normal git cmds in the shell | shell | | bash | | zsh | + | fish | Scenario Outline: Wrapped `git add` can handle files with spaces properly Given I am in a git repository And an empty file named "file with spaces.txt" When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type "git add 1" - And I type "exit" + And I close the shell `` Then the exit status should be 0 And the output should match /new file:\s+\[1\] file with spaces.txt/ Examples: | shell | | bash | | zsh | + | fish | Scenario Outline: Wrapped `git reset` can handle files with spaces properly @@ -58,11 +60,10 @@ Feature: optional wrapping of normal git cmds in the shell And an empty file named "file with spaces.txt" And I successfully run `git add "file with spaces.txt"` When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type "git reset 1" - And I type "exit" - And I stop the command "" + And I close the shell `` Then the exit status should be 0 When I run `scmpuff status` Then the stdout from "scmpuff status" should contain: @@ -73,6 +74,7 @@ Feature: optional wrapping of normal git cmds in the shell | shell | | bash | | zsh | + | fish | @recent-git-only @@ -84,11 +86,10 @@ Feature: optional wrapping of normal git cmds in the shell And a 4 byte file named "foo.bar" And I successfully run `git add foo.bar` When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" And I type "git restore --staged 1" - And I type "exit" - And I stop the command "" + And I close the shell `` Then the exit status should be 0 When I run `scmpuff status` Then the stdout from "scmpuff status" should contain: @@ -101,6 +102,7 @@ Feature: optional wrapping of normal git cmds in the shell | shell | | bash | | zsh | + | fish | Scenario Outline: Wrapped `git add` can handle shell expansions Given I am in a git repository @@ -108,12 +110,11 @@ Feature: optional wrapping of normal git cmds in the shell And an empty file named "file2.txt" And an empty file named "untracked file.txt" When I run `` interactively - And I type `eval "$(scmpuff init -ws)"` + And I initialize scmpuff in `` And I type "scmpuff_status" - And I type `FILE="file with spaces.txt"` + And I type `` And I type `git add "$FILE" 2` - And I type "exit" - Then the exit status should be 0 + And I close the shell `` And the output should contain: """ new file: [1] file with spaces.txt @@ -126,7 +127,9 @@ Feature: optional wrapping of normal git cmds in the shell """ untracked: [3] untracked file.txt """ + Then the exit status should be 0 Examples: - | shell | - | bash | - | zsh | + | shell | setfile | + | bash | FILE="file with spaces.txt" | + | zsh | FILE="file with spaces.txt" | + | fish | set FILE "file with spaces.txt" | diff --git a/features/support/env.rb b/features/support/env.rb index 8530f59..dd96ffc 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -15,3 +15,19 @@ tmpdir = Dir.mktmpdir("aruba") cd tmpdir end + +When(/I initialize scmpuff in `(.*)`/) do |shell| + if shell == "fish" + type %{scmpuff init -w --shell=fish | source} + else + type %{eval "$(scmpuff init -ws)"} + end +end + +When(/I close the shell `(.*)`/) do |shell| + status_var = shell == "fish" ? "$status" : "$?" + type "exit #{status_var}" + # fish doesn't run the inputted commands until stdin is closed + close_input + step("I stop the command \"#{shell}\"") +end diff --git a/go.mod b/go.mod index b6b48e7..1299043 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,10 @@ module github.com/mroth/scmpuff -go 1.16 +go 1.17 require github.com/spf13/cobra v1.3.0 + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +)