diff --git a/.travis.yml b/.travis.yml index e7b34ee1..990a7f00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,9 @@ language: python sudo: false matrix: include: + - os: linux + dist: trusty + python: "3.6" - os: linux dist: trusty python: "3.5" @@ -29,7 +32,7 @@ addons: - python3-commandnotfound before_install: - if [[ $TRAVIS_OS_NAME == "osx" ]]; then brew update ; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then brew install $FORMULA; fi + - if [[ $TRAVIS_OS_NAME == "osx" ]]; then if brew ls --versions $FORMULA; then brew upgrade $FORMULA || echo Python is up to date; else brew install $FORMULA; fi; fi - if [[ $TRAVIS_OS_NAME == "osx" ]]; then virtualenv venv -p $FORMULA; fi - if [[ $TRAVIS_OS_NAME == "osx" ]]; then source venv/bin/activate; fi - pip install -U pip @@ -42,7 +45,7 @@ script: - flake8 - export COVERAGE_PYTHON_VERSION=python-${TRAVIS_PYTHON_VERSION:0:1} - export RUN_TESTS="coverage run --source=thefuck,tests -m py.test -v --capture=sys tests" - - if [[ $TRAVIS_PYTHON_VERSION == 3.5 && $TRAVIS_OS_NAME != "osx" ]]; then $RUN_TESTS --enable-functional; fi - - if [[ $TRAVIS_PYTHON_VERSION != 3.5 || $TRAVIS_OS_NAME == "osx" ]]; then $RUN_TESTS; fi + - if [[ $TRAVIS_PYTHON_VERSION == 3.6 && $TRAVIS_OS_NAME != "osx" ]]; then $RUN_TESTS --enable-functional; fi + - if [[ $TRAVIS_PYTHON_VERSION != 3.6 || $TRAVIS_OS_NAME == "osx" ]]; then $RUN_TESTS; fi after_success: - - coveralls + - if [[ $TRAVIS_PYTHON_VERSION == 3.6 && $TRAVIS_OS_NAME != "osx" ]]; then coveralls; fi diff --git a/README.md b/README.md index b002faf5..530e7862 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ sudo -H pip install thefuck --upgrade The Fuck tries to match a rule for the previous command, creates a new command using the matched rule and runs it. Rules enabled by default are as follows: -* `aws_cli` – fixes misspelled commands like `aws dynamdb scan` +* `ag_literal` – adds `-Q` to `ag` when suggested; +* `aws_cli` – fixes misspelled commands like `aws dynamdb scan`; * `cargo` – runs `cargo build` instead of `cargo`; * `cargo_no_command` – fixes wrongs commands like `cargo buid`; * `cd_correction` – spellchecks and correct failed cd commands; @@ -163,7 +164,9 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `fab_command_not_found` – fix misspelled fabric commands; * `fix_alt_space` – replaces Alt+Space with Space character; * `fix_file` – opens a file with an error in your `$EDITOR`; +* `gem_unknown_command` – fixes wrong `gem` commands; * `git_add` – fixes *"pathspec 'foo' did not match any file(s) known to git."*; +* `git_add_force` – adds `--force` to `git add ...` when paths are .gitignore'd; * `git_bisect_usage` – fixes `git bisect strt`, `git bisect goood`, `git bisect rset`, etc. when bisecting; * `git_branch_delete` – changes `git branch -d` to `git branch -D`; * `git_branch_exists` – offers `git branch -d foo`, `git branch -D foo` or `git checkout foo` when creating a branch that already exists; @@ -172,6 +175,7 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `git_diff_no_index` – adds `--no-index` to previous `git diff` on untracked files; * `git_diff_staged` – adds `--staged` to previous `git diff` with unexpected output; * `git_fix_stash` – fixes `git stash` commands (misspelled subcommand and missing `save`); +* `git_flag_after_filename` – fixes `fatal: bad flag '...' after filename` * `git_help_aliased` – fixes `git help ` commands replacing with the aliased command; * `git_not_command` – fixes wrong git commands like `git brnch`; * `git_pull` – sets upstream before executing previous `git pull`; @@ -180,9 +184,14 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `git_push` – adds `--set-upstream origin $branch` to previous failed `git push`; * `git_push_pull` – runs `git pull` when `push` was rejected; * `git_rebase_no_changes` – runs `git rebase --skip` instead of `git rebase --continue` when there are no changes; +* `git_rm_local_modifications` – adds `-f` or `--cached` when you try to `rm` a locally modified file; * `git_rm_recursive` – adds `-r` when you try to `rm` a directory; +* `git_rm_staged` – adds `-f` or `--cached` when you try to `rm` a file with staged changes +* `git_rebase_merge_dir` – offers `git rebase (--continue | --abort | --skip)` or removing the `.git/rebase-merge` dir when a rebase is in progress; * `git_remote_seturl_add` – runs `git remote add` when `git remote set_url` on nonexistant remote; -* `git_stash` – stashes you local modifications before rebasing or switching branch; +* `git_stash` – stashes your local modifications before rebasing or switching branch; +* `git_stash_pop` – adds your local modifications before popping stash, then resets; +* `git_tag_force` – adds `--force` to `git tag ` when the tag already exists; * `git_two_dashes` – adds a missing dash to commands like `git commit -amend` or `git rebase -continue`; * `go_run` – appends `.go` extension when compiling/running Go programs; * `gradle_no_task` – fixes not found or ambiguous `gradle` task; @@ -194,11 +203,13 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `has_exists_script` – prepends `./` when script/binary exists; * `heroku_not_command` – fixes wrong `heroku` commands like `heroku log`; * `history` – tries to replace command with most similar command from history; +* `ifconfig_device_not_found` – fixes wrong device names like `wlan0` to `wlp2s0`; * `java` – removes `.java` extension when running Java programs; * `javac` – appends missing `.java` when compiling Java files; * `lein_not_task` – fixes wrong `lein` tasks like `lein rpl`; * `ln_no_hard_link` – catches hard link creation on directories, suggest symbolic link; * `ln_s_order` – fixes `ln -s` arguments order; +* `ls_all` – adds `-A` to `ls` when output is empty; * `ls_lah` – adds `-lah` to `ls`; * `man` – changes manual section; * `man_no_space` – fixes man commands without spaces, for example `mandiff`; @@ -220,10 +231,12 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `react_native_command_unrecognized` – fixes unrecognized `react-native` commands; * `remove_trailing_cedilla` – remove trailling cedillas `รง`, a common typo for european keyboard layouts; * `rm_dir` – adds `-rf` when you trying to remove directory; +* `scm_correction` – corrects wrong scm like `hg log` to `git log`; * `sed_unterminated_s` – adds missing '/' to `sed`'s `s` commands; * `sl_ls` – changes `sl` to `ls`; * `ssh_known_hosts` – removes host from `known_hosts` on warning; * `sudo` – prepends `sudo` to previous command if it failed because of permissions; +* `sudo_command_from_user_path` – runs commands from users `$PATH` with `sudo`; * `switch_lang` – switches command from your local layout to en; * `systemctl` – correctly orders parameters of confusing `systemctl`; * `test.py` – runs `py.test` instead of `test.py`; @@ -235,6 +248,8 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `vagrant_up` – starts up the vagrant instance; * `whois` – fixes `whois` command; * `workon_doesnt_exists` – fixes `virtualenvwrapper` env name os suggests to create new. +* `yarn_alias` – fixes aliased `yarn` commands like `yarn ls`; +* `yarn_command_not_found` – fixes misspelled `yarn` commands; Enabled by default only on specific platforms: @@ -272,7 +287,9 @@ side_effect(old_command: Command, fixed_command: str) -> None ``` and optional `enabled_by_default`, `requires_output` and `priority` variables. -`Command` has three attributes: `script`, `stdout` and `stderr`. +`Command` has three attributes: `script`, `stdout`, `stderr` and `script_parts`. +Rule shouldn't change `Command`. + *Rules api changed in 3.0:* For accessing settings in rule you need to import it with `from thefuck.conf import settings`. `settings` is a special object filled with `~/.config/thefuck/settings.py` and values from env ([see more below](#settings)). diff --git a/appveyor.yml b/appveyor.yml index d7072f7f..005e0d1a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,6 +6,7 @@ environment: - PYTHON: "C:/Python33" - PYTHON: "C:/Python34" - PYTHON: "C:/Python35" + - PYTHON: "C:/Python36" init: - "ECHO %PYTHON%" diff --git a/setup.py b/setup.py index cab15aa8..4149f663 100755 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ elif (3, 0) < version < (3, 3): ' ({}.{} detected).'.format(*version)) sys.exit(-1) -VERSION = '3.11' +VERSION = '3.14' install_requires = ['psutil', 'colorama', 'six', 'decorator'] extras_require = {':python_version<"3.4"': ['pathlib2'], diff --git a/tests/functional/test_bash.py b/tests/functional/test_bash.py index 45496d1d..6503475b 100644 --- a/tests/functional/test_bash.py +++ b/tests/functional/test_bash.py @@ -3,18 +3,11 @@ from tests.functional.plots import with_confirmation, without_confirmation, \ refuse_with_confirmation, history_changed, history_not_changed, \ select_command_with_arrows, how_to_configure -containers = ((u'thefuck/ubuntu-python3-bash', - u'''FROM ubuntu:latest - RUN apt-get update - RUN apt-get install -yy python3 python3-pip python3-dev git - RUN pip3 install -U setuptools - RUN ln -s /usr/bin/pip3 /usr/bin/pip''', +containers = ((u'thefuck/python3-bash', + u'FROM python:3', u'bash'), - (u'thefuck/ubuntu-python2-bash', - u'''FROM ubuntu:latest - RUN apt-get update - RUN apt-get install -yy python python-pip python-dev git - RUN pip2 install -U pip setuptools''', + (u'thefuck/python2-bash', + u'FROM python:2', u'bash')) diff --git a/tests/functional/test_fish.py b/tests/functional/test_fish.py index aec038a4..657ebf19 100644 --- a/tests/functional/test_fish.py +++ b/tests/functional/test_fish.py @@ -2,19 +2,20 @@ import pytest from tests.functional.plots import with_confirmation, without_confirmation, \ refuse_with_confirmation, select_command_with_arrows -containers = (('thefuck/ubuntu-python3-fish', - u'''FROM ubuntu:latest +containers = (('thefuck/python3-fish', + u'''FROM python:3 + # Use jessie-backports since it has the fish package. See here for details: + # https://github.com/tianon/docker-brew-debian/blob/88ae21052affd8a14553bb969f9d41c464032122/jessie/backports/Dockerfile + RUN awk '$1 ~ "^deb" { $3 = $3 "-backports"; print; exit }' /etc/apt/sources.list > /etc/apt/sources.list.d/backports.list RUN apt-get update - RUN apt-get install -yy python3 python3-pip python3-dev fish git - RUN pip3 install -U setuptools - RUN ln -s /usr/bin/pip3 /usr/bin/pip RUN apt-get install -yy fish''', u'fish'), - ('thefuck/ubuntu-python2-fish', - u'''FROM ubuntu:latest + ('thefuck/python2-fish', + u'''FROM python:2 + # Use jessie-backports since it has the fish package. See here for details: + # https://github.com/tianon/docker-brew-debian/blob/88ae21052affd8a14553bb969f9d41c464032122/jessie/backports/Dockerfile + RUN awk '$1 ~ "^deb" { $3 = $3 "-backports"; print; exit }' /etc/apt/sources.list > /etc/apt/sources.list.d/backports.list RUN apt-get update - RUN apt-get install -yy python python-pip python-dev git - RUN pip2 install -U pip setuptools RUN apt-get install -yy fish''', u'fish')) diff --git a/tests/functional/test_performance.py b/tests/functional/test_performance.py index d935aec1..b410114e 100644 --- a/tests/functional/test_performance.py +++ b/tests/functional/test_performance.py @@ -2,11 +2,7 @@ import pytest import time dockerfile = u''' -FROM ubuntu:latest -RUN apt-get update -RUN apt-get install -yy python3 python3-pip python3-dev git -RUN pip3 install -U setuptools -RUN ln -s /usr/bin/pip3 /usr/bin/pip +FROM python:3 RUN adduser --disabled-password --gecos '' test ENV SEED "{seed}" WORKDIR /src @@ -42,7 +38,7 @@ def plot(proc, TIMEOUT): @pytest.mark.functional @pytest.mark.benchmark(min_rounds=10) def test_performance(spawnu, TIMEOUT, benchmark): - proc = spawnu(u'thefuck/ubuntu-python3-bash-performance', + proc = spawnu(u'thefuck/python3-bash-performance', dockerfile, u'bash') proc.sendline(u'pip install /src') proc.sendline(u'su test') diff --git a/tests/functional/test_tcsh.py b/tests/functional/test_tcsh.py index e7d621b5..12cabe30 100644 --- a/tests/functional/test_tcsh.py +++ b/tests/functional/test_tcsh.py @@ -2,19 +2,14 @@ import pytest from tests.functional.plots import with_confirmation, without_confirmation, \ refuse_with_confirmation, select_command_with_arrows -containers = (('thefuck/ubuntu-python3-tcsh', - u'''FROM ubuntu:latest +containers = (('thefuck/python3-tcsh', + u'''FROM python:3 RUN apt-get update - RUN apt-get install -yy python3 python3-pip python3-dev git - RUN pip3 install -U setuptools - RUN ln -s /usr/bin/pip3 /usr/bin/pip RUN apt-get install -yy tcsh''', u'tcsh'), - ('thefuck/ubuntu-python2-tcsh', - u'''FROM ubuntu:latest + ('thefuck/python2-tcsh', + u'''FROM python:2 RUN apt-get update - RUN apt-get install -yy python python-pip python-dev git - RUN pip2 install -U pip setuptools RUN apt-get install -yy tcsh''', u'tcsh')) diff --git a/tests/functional/test_zsh.py b/tests/functional/test_zsh.py index 6c12a3d2..7e2d78d0 100644 --- a/tests/functional/test_zsh.py +++ b/tests/functional/test_zsh.py @@ -3,19 +3,14 @@ from tests.functional.plots import with_confirmation, without_confirmation, \ refuse_with_confirmation, history_changed, history_not_changed, \ select_command_with_arrows, how_to_configure -containers = (('thefuck/ubuntu-python3-zsh', - u'''FROM ubuntu:latest +containers = (('thefuck/python3-zsh', + u'''FROM python:3 RUN apt-get update - RUN apt-get install -yy python3 python3-pip python3-dev git - RUN pip3 install -U setuptools - RUN ln -s /usr/bin/pip3 /usr/bin/pip RUN apt-get install -yy zsh''', u'zsh'), - ('thefuck/ubuntu-python2-zsh', - u'''FROM ubuntu:latest + ('thefuck/python2-zsh', + u'''FROM python:2 RUN apt-get update - RUN apt-get install -yy python python-pip python-dev git - RUN pip2 install -U pip setuptools RUN apt-get install -yy zsh''', u'zsh')) diff --git a/tests/rules/test_ag_literal.py b/tests/rules/test_ag_literal.py new file mode 100644 index 00000000..4040d5db --- /dev/null +++ b/tests/rules/test_ag_literal.py @@ -0,0 +1,25 @@ +import pytest +from thefuck.rules.ag_literal import get_new_command, match +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return ('ERR: Bad regex! pcre_compile() failed at position 1: missing )\n' + 'If you meant to search for a literal string, run ag with -Q\n') + + +@pytest.mark.parametrize('script', ['ag \(']) +def test_match(script, stderr): + assert match(Command(script=script, stderr=stderr)) + + +@pytest.mark.parametrize('script', ['ag foo']) +def test_not_match(script): + assert not match(Command(script=script)) + + +@pytest.mark.parametrize('script, new_cmd', [ + ('ag \(', 'ag -Q \(')]) +def test_get_new_command(script, new_cmd, stderr): + assert get_new_command((Command(script=script, stderr=stderr))) == new_cmd diff --git a/tests/rules/test_apt_get.py b/tests/rules/test_apt_get.py index 1226e522..d669037c 100644 --- a/tests/rules/test_apt_get.py +++ b/tests/rules/test_apt_get.py @@ -7,6 +7,8 @@ from tests.utils import Command (Command(script='vim', stderr='vim: command not found'), [('vim', 'main'), ('vim-tiny', 'main')]), (Command(script='sudo vim', stderr='vim: command not found'), + [('vim', 'main'), ('vim-tiny', 'main')]), + (Command(script='vim', stderr="The program 'vim' is currently not installed. You can install it by typing: sudo apt install vim"), [('vim', 'main'), ('vim-tiny', 'main')])]) def test_match(mocker, command, packages): mocker.patch('thefuck.rules.apt_get.which', return_value=None) diff --git a/tests/rules/test_gem_unknown_command.py b/tests/rules/test_gem_unknown_command.py new file mode 100644 index 00000000..c5d30887 --- /dev/null +++ b/tests/rules/test_gem_unknown_command.py @@ -0,0 +1,82 @@ +import pytest +from six import BytesIO +from thefuck.rules.gem_unknown_command import match, get_new_command +from tests.utils import Command + +stderr = ''' +ERROR: While executing gem ... (Gem::CommandLineError) + Unknown command {} +''' + +gem_help_commands_stdout = b''' +GEM commands are: + + build Build a gem from a gemspec + cert Manage RubyGems certificates and signing settings + check Check a gem repository for added or missing files + cleanup Clean up old versions of installed gems + contents Display the contents of the installed gems + dependency Show the dependencies of an installed gem + environment Display information about the RubyGems environment + fetch Download a gem and place it in the current directory + generate_index Generates the index files for a gem server directory + help Provide help on the 'gem' command + install Install a gem into the local repository + list Display local gems whose name matches REGEXP + lock Generate a lockdown list of gems + mirror Mirror all gem files (requires rubygems-mirror) + open Open gem sources in editor + outdated Display all gems that need updates + owner Manage gem owners of a gem on the push server + pristine Restores installed gems to pristine condition from files + located in the gem cache + push Push a gem up to the gem server + query Query gem information in local or remote repositories + rdoc Generates RDoc for pre-installed gems + search Display remote gems whose name matches REGEXP + server Documentation and gem repository HTTP server + sources Manage the sources and cache file RubyGems uses to search + for gems + specification Display gem specification (in yaml) + stale List gems along with access times + uninstall Uninstall gems from the local repository + unpack Unpack an installed gem to the current directory + update Update installed gems to the latest version + which Find the location of a library file you can require + yank Remove a pushed gem from the index + +For help on a particular command, use 'gem help COMMAND'. + +Commands may be abbreviated, so long as they are unambiguous. +e.g. 'gem i rake' is short for 'gem install rake'. + +''' + + +@pytest.fixture(autouse=True) +def gem_help_commands(mocker): + patch = mocker.patch('subprocess.Popen') + patch.return_value.stdout = BytesIO(gem_help_commands_stdout) + return patch + + +@pytest.mark.parametrize('script, command', [ + ('gem isntall jekyll', 'isntall'), + ('gem last --local', 'last')]) +def test_match(script, command): + assert match(Command(script, stderr=stderr.format(command))) + + +@pytest.mark.parametrize('script, stderr', [ + ('gem install jekyll', ''), + ('git log', stderr.format('log'))]) +def test_not_match(script, stderr): + assert not match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, stderr, result', [ + ('gem isntall jekyll', stderr.format('isntall'), 'gem install jekyll'), + ('gem last --local', stderr.format('last'), 'gem list --local')]) +def test_get_new_command(script, stderr, result): + new_command = get_new_command(Command(script, stderr=stderr)) + assert new_command[0] == result diff --git a/tests/rules/test_git_add_force.py b/tests/rules/test_git_add_force.py new file mode 100644 index 00000000..abe2bd79 --- /dev/null +++ b/tests/rules/test_git_add_force.py @@ -0,0 +1,22 @@ +import pytest +from thefuck.rules.git_add_force import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return ('The following paths are ignored by one of your .gitignore files:\n' + 'dist/app.js\n' + 'dist/background.js\n' + 'dist/options.js\n' + 'Use -f if you really want to add them.\n') + + +def test_match(stderr): + assert match(Command('git add dist/*.js', stderr=stderr)) + assert not match(Command('git add dist/*.js')) + + +def test_get_new_command(stderr): + assert get_new_command(Command('git add dist/*.js', stderr=stderr)) \ + == "git add --force dist/*.js" diff --git a/tests/rules/test_git_flag_after_filename.py b/tests/rules/test_git_flag_after_filename.py new file mode 100644 index 00000000..4cf49957 --- /dev/null +++ b/tests/rules/test_git_flag_after_filename.py @@ -0,0 +1,31 @@ +import pytest +from thefuck.rules.git_flag_after_filename import match, get_new_command +from tests.utils import Command + +command1 = Command('git log README.md -p', + stderr="fatal: bad flag '-p' used after filename") +command2 = Command('git log README.md -p CONTRIBUTING.md', + stderr="fatal: bad flag '-p' used after filename") +command3 = Command('git log -p README.md --name-only', + stderr="fatal: bad flag '--name-only' used after filename") + + +@pytest.mark.parametrize('command', [ + command1, command2, command3]) +def test_match(command): + assert match(command) + + +@pytest.mark.parametrize('command', [ + Command('git log README.md'), + Command('git log -p README.md')]) +def test_not_match(command): + assert not match(command) + + +@pytest.mark.parametrize('command, result', [ + (command1, "git log -p README.md"), + (command2, "git log -p README.md CONTRIBUTING.md"), + (command3, "git log -p --name-only README.md")]) +def test_get_new_command(command, result): + assert get_new_command(command) == result diff --git a/tests/rules/test_git_push.py b/tests/rules/test_git_push.py index d6654964..1eb61b23 100644 --- a/tests/rules/test_git_push.py +++ b/tests/rules/test_git_push.py @@ -23,6 +23,8 @@ def test_match(stderr): def test_get_new_command(stderr): assert get_new_command(Command('git push', stderr=stderr))\ == "git push --set-upstream origin master" + assert get_new_command(Command('git push -u', stderr=stderr))\ + == "git push --set-upstream origin master" assert get_new_command(Command('git push -u origin', stderr=stderr))\ == "git push --set-upstream origin master" assert get_new_command(Command('git push --set-upstream origin', stderr=stderr))\ diff --git a/tests/rules/test_git_rebase_merge_dir.py b/tests/rules/test_git_rebase_merge_dir.py new file mode 100644 index 00000000..3e43ea70 --- /dev/null +++ b/tests/rules/test_git_rebase_merge_dir.py @@ -0,0 +1,40 @@ +import pytest +from thefuck.rules.git_rebase_merge_dir import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return ('\n\nIt seems that there is already a rebase-merge directory, and\n' + 'I wonder if you are in the middle of another rebase. If that is the\n' + 'case, please try\n' + '\tgit rebase (--continue | --abort | --skip)\n' + 'If that is not the case, please\n' + '\trm -fr "/foo/bar/baz/egg/.git/rebase-merge"\n' + 'and run me again. I am stopping in case you still have something\n' + 'valuable there.\n') + + +@pytest.mark.parametrize('script', [ + ('git rebase master'), ('git rebase -skip'), ('git rebase')]) +def test_match(stderr, script): + assert match(Command(script=script, stderr=stderr)) + + +@pytest.mark.parametrize('script', ['git rebase master', 'git rebase -abort']) +def test_not_match(script): + assert not match(Command(script=script)) + + +@pytest.mark.parametrize('script, result', [ + ('git rebase master', [ + 'git rebase --abort', 'git rebase --skip', 'git rebase --continue', + 'rm -fr "/foo/bar/baz/egg/.git/rebase-merge"']), + ('git rebase -skip', [ + 'git rebase --skip', 'git rebase --abort', 'git rebase --continue', + 'rm -fr "/foo/bar/baz/egg/.git/rebase-merge"']), + ('git rebase', [ + 'git rebase --skip', 'git rebase --abort', 'git rebase --continue', + 'rm -fr "/foo/bar/baz/egg/.git/rebase-merge"'])]) +def test_get_new_command(stderr, script, result): + assert get_new_command(Command(script=script, stderr=stderr)) == result diff --git a/tests/rules/test_git_rm_local_modifications.py b/tests/rules/test_git_rm_local_modifications.py new file mode 100644 index 00000000..1b0a4df6 --- /dev/null +++ b/tests/rules/test_git_rm_local_modifications.py @@ -0,0 +1,28 @@ +import pytest +from thefuck.rules.git_rm_local_modifications import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(target): + return ('error: the following file has local modifications:\n {}\n(use ' + '--cached to keep the file, or -f to force removal)').format(target) + + +@pytest.mark.parametrize('script, target', [ + ('git rm foo', 'foo'), + ('git rm foo bar', 'bar')]) +def test_match(stderr, script, target): + assert match(Command(script=script, stderr=stderr)) + + +@pytest.mark.parametrize('script', ['git rm foo', 'git rm foo bar', 'git rm']) +def test_not_match(script): + assert not match(Command(script=script, stderr='')) + + +@pytest.mark.parametrize('script, target, new_command', [ + ('git rm foo', 'foo', ['git rm --cached foo', 'git rm -f foo']), + ('git rm foo bar', 'bar', ['git rm --cached foo bar', 'git rm -f foo bar'])]) +def test_get_new_command(stderr, script, target, new_command): + assert get_new_command(Command(script=script, stderr=stderr)) == new_command diff --git a/tests/rules/test_git_rm_staged.py b/tests/rules/test_git_rm_staged.py new file mode 100644 index 00000000..125ae998 --- /dev/null +++ b/tests/rules/test_git_rm_staged.py @@ -0,0 +1,28 @@ +import pytest +from thefuck.rules.git_rm_staged import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(target): + return ('error: the following file has changes staged in the index:\n {}\n(use ' + '--cached to keep the file, or -f to force removal)').format(target) + + +@pytest.mark.parametrize('script, target', [ + ('git rm foo', 'foo'), + ('git rm foo bar', 'bar')]) +def test_match(stderr, script, target): + assert match(Command(script=script, stderr=stderr)) + + +@pytest.mark.parametrize('script', ['git rm foo', 'git rm foo bar', 'git rm']) +def test_not_match(script): + assert not match(Command(script=script, stderr='')) + + +@pytest.mark.parametrize('script, target, new_command', [ + ('git rm foo', 'foo', ['git rm --cached foo', 'git rm -f foo']), + ('git rm foo bar', 'bar', ['git rm --cached foo bar', 'git rm -f foo bar'])]) +def test_get_new_command(stderr, script, target, new_command): + assert get_new_command(Command(script=script, stderr=stderr)) == new_command diff --git a/tests/rules/test_git_stash_pop.py b/tests/rules/test_git_stash_pop.py new file mode 100644 index 00000000..1ff34686 --- /dev/null +++ b/tests/rules/test_git_stash_pop.py @@ -0,0 +1,18 @@ +import pytest +from thefuck.rules.git_stash_pop import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return '''error: Your local changes to the following files would be overwritten by merge:''' + + +def test_match(stderr): + assert match(Command('git stash pop', stderr=stderr)) + assert not match(Command('git stash')) + + +def test_get_new_command(stderr): + assert get_new_command(Command('git stash pop', stderr=stderr)) \ + == "git add . && git stash pop && git reset ." diff --git a/tests/rules/test_git_tag_force.py b/tests/rules/test_git_tag_force.py new file mode 100644 index 00000000..46c96fc6 --- /dev/null +++ b/tests/rules/test_git_tag_force.py @@ -0,0 +1,18 @@ +import pytest +from thefuck.rules.git_tag_force import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return '''fatal: tag 'alert' already exists''' + + +def test_match(stderr): + assert match(Command('git tag alert', stderr=stderr)) + assert not match(Command('git tag alert')) + + +def test_get_new_command(stderr): + assert get_new_command(Command('git tag alert', stderr=stderr)) \ + == "git tag --force alert" diff --git a/tests/rules/test_ifconfig_device_not_found.py b/tests/rules/test_ifconfig_device_not_found.py new file mode 100644 index 00000000..e09daffc --- /dev/null +++ b/tests/rules/test_ifconfig_device_not_found.py @@ -0,0 +1,53 @@ +import pytest +from six import BytesIO +from thefuck.rules.ifconfig_device_not_found import match, get_new_command +from tests.utils import Command + + +stderr = '{}: error fetching interface information: Device not found' + +stdout = b''' +wlp2s0 Link encap:Ethernet HWaddr 5c:51:4f:7c:58:5d + inet addr:192.168.0.103 Bcast:192.168.0.255 Mask:255.255.255.0 + inet6 addr: fe80::be23:69b9:96d2:6d39/64 Scope:Link + UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + RX packets:23581604 errors:0 dropped:0 overruns:0 frame:0 + TX packets:17017655 errors:0 dropped:0 overruns:0 carrier:0 + collisions:0 txqueuelen:1000 + RX bytes:16148429061 (16.1 GB) TX bytes:7067533695 (7.0 GB) +''' + + +@pytest.fixture(autouse=True) +def ifconfig(mocker): + mock = mocker.patch( + 'thefuck.rules.ifconfig_device_not_found.subprocess.Popen') + mock.return_value.stdout = BytesIO(stdout) + return mock + + +@pytest.mark.parametrize('script, stderr', [ + ('ifconfig wlan0', stderr.format('wlan0')), + ('ifconfig -s eth0', stderr.format('eth0')), +]) +def test_match(script, stderr): + assert match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, stderr', [ + ('config wlan0', + 'wlan0: error fetching interface information: Device not found'), + ('ifconfig eth0', ''), +]) +def test_not_match(script, stderr): + assert not match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, result', [ + ('ifconfig wlan0', ['ifconfig wlp2s0']), + ('ifconfig -s wlan0', ['ifconfig -s wlp2s0']), +]) +def test_get_new_comman(script, result): + new_command = get_new_command( + Command(script, stderr=stderr.format('wlan0'))) + assert new_command == result diff --git a/tests/rules/test_ls_all.py b/tests/rules/test_ls_all.py new file mode 100644 index 00000000..475f4e9c --- /dev/null +++ b/tests/rules/test_ls_all.py @@ -0,0 +1,12 @@ +from thefuck.rules.ls_all import match, get_new_command +from tests.utils import Command + + +def test_match(): + assert match(Command(script='ls')) + assert not match(Command(script='ls', stdout='file.py\n')) + + +def test_get_new_command(): + assert get_new_command(Command(script='ls empty_dir')) == 'ls -A empty_dir' + assert get_new_command(Command(script='ls')) == 'ls -A' diff --git a/tests/rules/test_man.py b/tests/rules/test_man.py index 01eab173..c4714881 100644 --- a/tests/rules/test_man.py +++ b/tests/rules/test_man.py @@ -23,7 +23,8 @@ def test_not_match(command): @pytest.mark.parametrize('command, new_command', [ - (Command('man read'), ['man 3 read', 'man 2 read']), + (Command('man read'), ['man 3 read', 'man 2 read', 'read --help']), + (Command('man missing', stderr="No manual entry for missing\n"), ['missing --help']), (Command('man 2 read'), 'man 3 read'), (Command('man 3 read'), 'man 2 read'), (Command('man -s2 read'), 'man -s3 read'), diff --git a/tests/rules/test_scm_correction.py b/tests/rules/test_scm_correction.py new file mode 100644 index 00000000..7f02b905 --- /dev/null +++ b/tests/rules/test_scm_correction.py @@ -0,0 +1,46 @@ +import pytest +from thefuck.rules.scm_correction import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def get_actual_scm_mock(mocker): + return mocker.patch('thefuck.rules.scm_correction._get_actual_scm', + return_value=None) + + +@pytest.mark.parametrize('script, stderr, actual_scm', [ + ('git log', 'fatal: Not a git repository ' + '(or any of the parent directories): .git', + 'hg'), + ('hg log', "abort: no repository found in '/home/nvbn/exp/thefuck' " + "(.hg not found)!", + 'git')]) +def test_match(get_actual_scm_mock, script, stderr, actual_scm): + get_actual_scm_mock.return_value = actual_scm + assert match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, stderr, actual_scm', [ + ('git log', '', 'hg'), + ('git log', 'fatal: Not a git repository ' + '(or any of the parent directories): .git', + None), + ('hg log', "abort: no repository found in '/home/nvbn/exp/thefuck' " + "(.hg not found)!", + None), + ('not-scm log', "abort: no repository found in '/home/nvbn/exp/thefuck' " + "(.hg not found)!", + 'git')]) +def test_not_match(get_actual_scm_mock, script, stderr, actual_scm): + get_actual_scm_mock.return_value = actual_scm + assert not match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, actual_scm, result', [ + ('git log', 'hg', 'hg log'), + ('hg log', 'git', 'git log')]) +def test_get_new_command(get_actual_scm_mock, script, actual_scm, result): + get_actual_scm_mock.return_value = actual_scm + new_command = get_new_command(Command(script)) + assert new_command == result diff --git a/tests/rules/test_sudo_command_from_user_path.py b/tests/rules/test_sudo_command_from_user_path.py new file mode 100644 index 00000000..7d6dacee --- /dev/null +++ b/tests/rules/test_sudo_command_from_user_path.py @@ -0,0 +1,39 @@ +import pytest +from thefuck.rules.sudo_command_from_user_path import match, get_new_command +from tests.utils import Command + + +stderr = 'sudo: {}: command not found' + + +@pytest.fixture(autouse=True) +def which(mocker): + return mocker.patch('thefuck.rules.sudo_command_from_user_path.which', + return_value='/usr/bin/app') + + +@pytest.mark.parametrize('script, stderr', [ + ('sudo npm install -g react-native-cli', stderr.format('npm')), + ('sudo -u app appcfg update .', stderr.format('appcfg'))]) +def test_match(script, stderr): + assert match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, stderr, which_result', [ + ('npm --version', stderr.format('npm'), '/usr/bin/npm'), + ('sudo npm --version', '', '/usr/bin/npm'), + ('sudo npm --version', stderr.format('npm'), None)]) +def test_not_match(which, script, stderr, which_result): + which.return_value = which_result + assert not match(Command(script, stderr=stderr)) + + +@pytest.mark.parametrize('script, stderr, result', [ + ('sudo npm install -g react-native-cli', + stderr.format('npm'), + 'sudo env "PATH=$PATH" npm install -g react-native-cli'), + ('sudo -u app appcfg update .', + stderr.format('appcfg'), + 'sudo -u app env "PATH=$PATH" appcfg update .')]) +def test_get_new_command(script, stderr, result): + assert get_new_command(Command(script, stderr=stderr)) == result diff --git a/tests/rules/test_yarn_alias.py b/tests/rules/test_yarn_alias.py new file mode 100644 index 00000000..83d38117 --- /dev/null +++ b/tests/rules/test_yarn_alias.py @@ -0,0 +1,22 @@ +import pytest +from thefuck.rules.yarn_alias import match, get_new_command +from tests.utils import Command + + +stderr_remove = 'error Did you mean `yarn remove`?' + +stderr_list = 'error Did you mean `yarn list`?' + + +@pytest.mark.parametrize('command', [ + Command(script='yarn rm', stderr=stderr_remove), + Command(script='yarn ls', stderr=stderr_list)]) +def test_match(command): + assert match(command) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('yarn rm', stderr=stderr_remove), 'yarn remove'), + (Command('yarn ls', stderr=stderr_list), 'yarn list')]) +def test_get_new_command(command, new_command): + assert get_new_command(command) == new_command diff --git a/tests/rules/test_yarn_command_not_found.py b/tests/rules/test_yarn_command_not_found.py new file mode 100644 index 00000000..67b7823b --- /dev/null +++ b/tests/rules/test_yarn_command_not_found.py @@ -0,0 +1,111 @@ +# -*- encoding: utf-8 -*- + +from io import BytesIO +import pytest +from tests.utils import Command +from thefuck.rules.yarn_command_not_found import match, get_new_command + +stderr = ''' +error Command "{}" not found. +'''.format + +yarn_help_stdout = b''' + + Usage: yarn [command] [flags] + + Options: + + -h, --help output usage information + -V, --version output the version number + --verbose output verbose messages on internal operations + --offline trigger an error if any required dependencies are not available in local cache + --prefer-offline use network only if dependencies are not available in local cache + --strict-semver + --json + --ignore-scripts don't run lifecycle scripts + --har save HAR output of network traffic + --ignore-platform ignore platform checks + --ignore-engines ignore engines check + --ignore-optional ignore optional dependencies + --force ignore all caches + --no-bin-links don't generate bin links when setting up packages + --flat only allow one version of a package + --prod, --production [prod] + --no-lockfile don't read or generate a lockfile + --pure-lockfile don't generate a lockfile + --frozen-lockfile don't generate a lockfile and fail if an update is needed + --link-duplicates create hardlinks to the repeated modules in node_modules + --global-folder + --modules-folder rather than installing modules into the node_modules folder relative to the cwd, output them here + --cache-folder specify a custom folder to store the yarn cache + --mutex [:specifier] use a mutex to ensure only one yarn instance is executing + --no-emoji disable emoji in output + --proxy + --https-proxy + --no-progress disable progress bar + --network-concurrency maximum number of concurrent network requests + + Commands: + + - access + - add + - bin + - cache + - check + - clean + - config + - generate-lock-entry + - global + - import + - info + - init + - install + - licenses + - link + - list + - login + - logout + - outdated + - owner + - pack + - publish + - remove + - run + - tag + - team + - unlink + - upgrade + - upgrade-interactive + - version + - versions + - why + + Run `yarn help COMMAND` for more information on specific commands. + Visit https://yarnpkg.com/en/docs/cli/ to learn more about Yarn. +''' + + +@pytest.fixture(autouse=True) +def yarn_help(mocker): + patch = mocker.patch('thefuck.rules.yarn_command_not_found.Popen') + patch.return_value.stdout = BytesIO(yarn_help_stdout) + return patch + + +@pytest.mark.parametrize('command', [ + Command('yarn whyy webpack', stderr=stderr('whyy'))]) +def test_match(command): + assert match(command) + + +@pytest.mark.parametrize('command', [ + Command('npm nuild', stderr=stderr('nuild')), + Command('yarn install')]) +def test_not_match(command): + assert not match(command) + + +@pytest.mark.parametrize('command, result', [ + (Command('yarn whyy webpack', stderr=stderr('whyy')), 'yarn why webpack')]) +def test_get_new_command(command, result): + assert get_new_command(command)[0] == result diff --git a/tests/shells/test_bash.py b/tests/shells/test_bash.py index c3738f44..96661b53 100644 --- a/tests/shells/test_bash.py +++ b/tests/shells/test_bash.py @@ -56,3 +56,8 @@ class TestBash(object): def test_get_history(self, history_lines, shell): history_lines(['ls', 'rm']) assert list(shell.get_history()) == ['ls', 'rm'] + + def test_split_command(self, shell): + command = 'git log -p' + command_parts = ['git', 'log', '-p'] + assert shell.split_command(command) == command_parts diff --git a/tests/shells/test_fish.py b/tests/shells/test_fish.py index ba71edf5..24780c7b 100644 --- a/tests/shells/test_fish.py +++ b/tests/shells/test_fish.py @@ -76,11 +76,11 @@ class TestFish(object): def test_app_alias_alter_history(self, settings, shell): settings.alter_history = True - assert 'history --delete' in shell.app_alias('FUCK') - assert 'history --merge' in shell.app_alias('FUCK') + assert 'builtin history delete' in shell.app_alias('FUCK') + assert 'builtin history merge' in shell.app_alias('FUCK') settings.alter_history = False - assert 'history --delete' not in shell.app_alias('FUCK') - assert 'history --merge' not in shell.app_alias('FUCK') + assert 'builtin history delete' not in shell.app_alias('FUCK') + assert 'builtin history merge' not in shell.app_alias('FUCK') def test_get_history(self, history_lines, shell): history_lines(['- cmd: ls', ' when: 1432613911', diff --git a/thefuck/logs.py b/thefuck/logs.py index 15f232a4..e783c5f8 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -83,13 +83,14 @@ def how_to_configure_alias(configuration_details): print("Seems like {bold}fuck{reset} alias isn't configured!".format( bold=color(colorama.Style.BRIGHT), reset=color(colorama.Style.RESET_ALL))) + if configuration_details: - content, path = configuration_details print( "Please put {bold}{content}{reset} in your " - "{bold}{path}{reset}.".format( + "{bold}{path}{reset} and apply " + "changes with {bold}{reload}{reset} or restart your shell.".format( bold=color(colorama.Style.BRIGHT), reset=color(colorama.Style.RESET_ALL), - path=path, - content=content)) + **configuration_details)) + print('More details - https://github.com/nvbn/thefuck#manual-installation') diff --git a/thefuck/rules/ag_literal.py b/thefuck/rules/ag_literal.py new file mode 100644 index 00000000..a07ae07a --- /dev/null +++ b/thefuck/rules/ag_literal.py @@ -0,0 +1,10 @@ +from thefuck.utils import for_app + + +@for_app('ag') +def match(command): + return command.stderr.endswith('run ag with -Q\n') + + +def get_new_command(command): + return command.script.replace('ag', 'ag -Q', 1) diff --git a/thefuck/rules/apt_get.py b/thefuck/rules/apt_get.py index dafc68e0..abe742b3 100644 --- a/thefuck/rules/apt_get.py +++ b/thefuck/rules/apt_get.py @@ -29,7 +29,7 @@ def get_package(executable): def match(command): - if 'not found' in command.stderr: + if 'not found' in command.stderr or 'not installed' in command.stderr: executable = _get_executable(command) return not which(executable) and get_package(executable) else: diff --git a/thefuck/rules/brew_link.py b/thefuck/rules/brew_link.py index 23919a47..1c4d251e 100644 --- a/thefuck/rules/brew_link.py +++ b/thefuck/rules/brew_link.py @@ -8,7 +8,8 @@ def match(command): def get_new_command(command): - command.script_parts[1] = 'link' - command.script_parts.insert(2, '--overwrite') - command.script_parts.insert(3, '--dry-run') - return ' '.join(command.script_parts) + command_parts = command.script_parts[:] + command_parts[1] = 'link' + command_parts.insert(2, '--overwrite') + command_parts.insert(3, '--dry-run') + return ' '.join(command_parts) diff --git a/thefuck/rules/brew_uninstall.py b/thefuck/rules/brew_uninstall.py index d2306abd..f72062f7 100644 --- a/thefuck/rules/brew_uninstall.py +++ b/thefuck/rules/brew_uninstall.py @@ -8,6 +8,7 @@ def match(command): def get_new_command(command): - command.script_parts[1] = 'uninstall' - command.script_parts.insert(2, '--force') - return ' '.join(command.script_parts) + command_parts = command.script_parts[:] + command_parts[1] = 'uninstall' + command_parts.insert(2, '--force') + return ' '.join(command_parts) diff --git a/thefuck/rules/gem_unknown_command.py b/thefuck/rules/gem_unknown_command.py new file mode 100644 index 00000000..e20f3ba4 --- /dev/null +++ b/thefuck/rules/gem_unknown_command.py @@ -0,0 +1,32 @@ +import re +import subprocess +from thefuck.utils import for_app, eager, replace_command + + +@for_app('gem') +def match(command): + return ('ERROR: While executing gem ... (Gem::CommandLineError)' + in command.stderr + and 'Unknown command' in command.stderr) + + +def _get_unknown_command(command): + return re.findall(r'Unknown command (.*)$', command.stderr)[0] + + +@eager +def _get_all_commands(): + proc = subprocess.Popen(['gem', 'help', 'commands'], + stdout=subprocess.PIPE) + + for line in proc.stdout.readlines(): + line = line.decode() + + if line.startswith(' '): + yield line.strip().split(' ')[0] + + +def get_new_command(command): + unknown_command = _get_unknown_command(command) + all_commands = _get_all_commands() + return replace_command(command, unknown_command, all_commands) diff --git a/thefuck/rules/git_add_force.py b/thefuck/rules/git_add_force.py new file mode 100644 index 00000000..9adc0725 --- /dev/null +++ b/thefuck/rules/git_add_force.py @@ -0,0 +1,13 @@ +from thefuck.utils import replace_argument +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return ('add' in command.script_parts + and 'Use -f if you really want to add them.' in command.stderr) + + +@git_support +def get_new_command(command): + return replace_argument(command.script, 'add', 'add --force') diff --git a/thefuck/rules/git_flag_after_filename.py b/thefuck/rules/git_flag_after_filename.py new file mode 100644 index 00000000..bec0591e --- /dev/null +++ b/thefuck/rules/git_flag_after_filename.py @@ -0,0 +1,30 @@ +import re +from thefuck.specific.git import git_support + +error_pattern = "fatal: bad flag '(.*?)' used after filename" + + +@git_support +def match(command): + return re.search(error_pattern, command.stderr) + + +@git_support +def get_new_command(command): + command_parts = command.script_parts[:] + + # find the bad flag + bad_flag = re.search(error_pattern, command.stderr).group(1) + bad_flag_index = command_parts.index(bad_flag) + + # find the filename + for index in reversed(range(bad_flag_index)): + if command_parts[index][0] != '-': + filename_index = index + break + + # swap them + command_parts[bad_flag_index], command_parts[filename_index] = \ + command_parts[filename_index], command_parts[bad_flag_index] # noqa: E122 + + return u' '.join(command_parts) diff --git a/thefuck/rules/git_push.py b/thefuck/rules/git_push.py index 0a624eb9..85f59fe1 100644 --- a/thefuck/rules/git_push.py +++ b/thefuck/rules/git_push.py @@ -8,23 +8,29 @@ def match(command): and 'set-upstream' in command.stderr) +def _get_upstream_option_index(command_parts): + if '--set-upstream' in command_parts: + return command_parts.index('--set-upstream') + elif '-u' in command_parts: + return command_parts.index('-u') + else: + return None + + @git_support def get_new_command(command): # If --set-upstream or -u are passed, remove it and its argument. This is # because the remaining arguments are concatenated onto the command suggested # by git, which includes --set-upstream and its argument - upstream_option_index = -1 - try: - upstream_option_index = command.script_parts.index('--set-upstream') - except ValueError: - pass - try: - upstream_option_index = command.script_parts.index('-u') - except ValueError: - pass - if upstream_option_index is not -1: - command.script_parts.pop(upstream_option_index) - command.script_parts.pop(upstream_option_index) + command_parts = command.script_parts[:] + upstream_option_index = _get_upstream_option_index(command_parts) + + if upstream_option_index is not None: + command_parts.pop(upstream_option_index) + + # In case of `git push -u` we don't have next argument: + if len(command_parts) > upstream_option_index: + command_parts.pop(upstream_option_index) push_upstream = command.stderr.split('\n')[-3].strip().partition('git ')[2] - return replace_argument(" ".join(command.script_parts), 'push', push_upstream) + return replace_argument(" ".join(command_parts), 'push', push_upstream) diff --git a/thefuck/rules/git_rebase_merge_dir.py b/thefuck/rules/git_rebase_merge_dir.py new file mode 100644 index 00000000..910e3121 --- /dev/null +++ b/thefuck/rules/git_rebase_merge_dir.py @@ -0,0 +1,17 @@ +from difflib import get_close_matches +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return (' rebase' in command.script and + 'It seems that there is already a rebase-merge directory' in command.stderr and + 'I wonder if you are in the middle of another rebase' in command.stderr) + + +@git_support +def get_new_command(command): + command_list = ['git rebase --continue', 'git rebase --abort', 'git rebase --skip'] + rm_cmd = command.stderr.split('\n')[-4] + command_list.append(rm_cmd.strip()) + return get_close_matches(command.script, command_list, 4, 0) diff --git a/thefuck/rules/git_rm_local_modifications.py b/thefuck/rules/git_rm_local_modifications.py new file mode 100644 index 00000000..01b8de42 --- /dev/null +++ b/thefuck/rules/git_rm_local_modifications.py @@ -0,0 +1,19 @@ +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return (' rm ' in command.script and + 'error: the following file has local modifications' in command.stderr and + 'use --cached to keep the file, or -f to force removal' in command.stderr) + + +@git_support +def get_new_command(command): + command_parts = command.script_parts[:] + index = command_parts.index('rm') + 1 + command_parts.insert(index, '--cached') + command_list = [u' '.join(command_parts)] + command_parts[index] = '-f' + command_list.append(u' '.join(command_parts)) + return command_list diff --git a/thefuck/rules/git_rm_recursive.py b/thefuck/rules/git_rm_recursive.py index b9cad274..9053aa6c 100644 --- a/thefuck/rules/git_rm_recursive.py +++ b/thefuck/rules/git_rm_recursive.py @@ -10,6 +10,7 @@ def match(command): @git_support def get_new_command(command): - index = command.script_parts.index('rm') + 1 - command.script_parts.insert(index, '-r') - return u' '.join(command.script_parts) + command_parts = command.script_parts[:] + index = command_parts.index('rm') + 1 + command_parts.insert(index, '-r') + return u' '.join(command_parts) diff --git a/thefuck/rules/git_rm_staged.py b/thefuck/rules/git_rm_staged.py new file mode 100644 index 00000000..7532a1f6 --- /dev/null +++ b/thefuck/rules/git_rm_staged.py @@ -0,0 +1,19 @@ +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return (' rm ' in command.script and + 'error: the following file has changes staged in the index' in command.stderr and + 'use --cached to keep the file, or -f to force removal' in command.stderr) + + +@git_support +def get_new_command(command): + command_parts = command.script_parts[:] + index = command_parts.index('rm') + 1 + command_parts.insert(index, '--cached') + command_list = [u' '.join(command_parts)] + command_parts[index] = '-f' + command_list.append(u' '.join(command_parts)) + return command_list diff --git a/thefuck/rules/git_stash_pop.py b/thefuck/rules/git_stash_pop.py new file mode 100644 index 00000000..2073c234 --- /dev/null +++ b/thefuck/rules/git_stash_pop.py @@ -0,0 +1,18 @@ +from thefuck.shells import shell +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return ('stash' in command.script + and 'pop' in command.script + and 'Your local changes to the following files would be overwritten by merge' in command.stderr) + + +@git_support +def get_new_command(command): + return shell.and_('git add .', 'git stash pop', 'git reset .') + + +# make it come before the other applicable rules +priority = 900 diff --git a/thefuck/rules/git_tag_force.py b/thefuck/rules/git_tag_force.py new file mode 100644 index 00000000..6f6de7f2 --- /dev/null +++ b/thefuck/rules/git_tag_force.py @@ -0,0 +1,13 @@ +from thefuck.utils import replace_argument +from thefuck.specific.git import git_support + + +@git_support +def match(command): + return ('tag' in command.script_parts + and 'already exists' in command.stderr) + + +@git_support +def get_new_command(command): + return replace_argument(command.script, 'tag', 'tag --force') diff --git a/thefuck/rules/ifconfig_device_not_found.py b/thefuck/rules/ifconfig_device_not_found.py new file mode 100644 index 00000000..f8692236 --- /dev/null +++ b/thefuck/rules/ifconfig_device_not_found.py @@ -0,0 +1,25 @@ +import subprocess +from thefuck.utils import for_app, replace_command, eager +import sys + +@for_app('ifconfig') +def match(command): + return 'error fetching interface information: Device not found' \ + in command.stderr + + +@eager +def _get_possible_interfaces(): + proc = subprocess.Popen(['ifconfig', '-a'], stdout=subprocess.PIPE) + for line in proc.stdout.readlines(): + line = line.decode() + if line and line != '\n' and not line.startswith(' '): + yield line.split(' ')[0] + + +def get_new_command(command): + interface = command.stderr.split(' ')[0][:-1] + possible_interfaces = _get_possible_interfaces() + return replace_command(command, interface, possible_interfaces) + + diff --git a/thefuck/rules/ls_all.py b/thefuck/rules/ls_all.py new file mode 100644 index 00000000..e898d17d --- /dev/null +++ b/thefuck/rules/ls_all.py @@ -0,0 +1,10 @@ +from thefuck.utils import for_app + + +@for_app('ls') +def match(command): + return command.stdout.strip() == '' + + +def get_new_command(command): + return ' '.join(['ls', '-A'] + command.script_parts[1:]) diff --git a/thefuck/rules/man.py b/thefuck/rules/man.py index ead1361b..e4ec54d9 100644 --- a/thefuck/rules/man.py +++ b/thefuck/rules/man.py @@ -12,10 +12,22 @@ def get_new_command(command): if '2' in command.script: return command.script.replace("2", "3") + last_arg = command.script_parts[-1] + help_command = last_arg + ' --help' + + # If there are no man pages for last_arg, suggest `last_arg --help` instead. + # Otherwise, suggest `--help` after suggesting other man page sections. + if command.stderr.strip() == 'No manual entry for ' + last_arg: + return [help_command] + split_cmd2 = command.script_parts split_cmd3 = split_cmd2[:] split_cmd2.insert(1, ' 2 ') split_cmd3.insert(1, ' 3 ') - return ["".join(split_cmd3), "".join(split_cmd2)] + return [ + "".join(split_cmd3), + "".join(split_cmd2), + help_command, + ] diff --git a/thefuck/rules/python_command.py b/thefuck/rules/python_command.py index b4c321b7..98c56f72 100644 --- a/thefuck/rules/python_command.py +++ b/thefuck/rules/python_command.py @@ -6,9 +6,8 @@ from thefuck.specific.sudo import sudo_support @sudo_support def match(command): - toks = command.script_parts - return (toks - and toks[0].endswith('.py') + return (command.script_parts + and command.script_parts[0].endswith('.py') and ('Permission denied' in command.stderr or 'command not found' in command.stderr)) diff --git a/thefuck/rules/scm_correction.py b/thefuck/rules/scm_correction.py new file mode 100644 index 00000000..0b2fc7e5 --- /dev/null +++ b/thefuck/rules/scm_correction.py @@ -0,0 +1,32 @@ +from thefuck.utils import for_app, memoize +from thefuck.system import Path + +path_to_scm = { + '.git': 'git', + '.hg': 'hg', +} + +wrong_scm_patterns = { + 'git': 'fatal: Not a git repository', + 'hg': 'abort: no repository found', +} + + +@memoize +def _get_actual_scm(): + for path, scm in path_to_scm.items(): + if Path(path).is_dir(): + return scm + + +@for_app(*wrong_scm_patterns.keys()) +def match(command): + scm = command.script_parts[0] + pattern = wrong_scm_patterns[scm] + + return pattern in command.stderr and _get_actual_scm() + + +def get_new_command(command): + scm = _get_actual_scm() + return u' '.join([scm] + command.script_parts[1:]) diff --git a/thefuck/rules/sudo_command_from_user_path.py b/thefuck/rules/sudo_command_from_user_path.py new file mode 100644 index 00000000..39ddace9 --- /dev/null +++ b/thefuck/rules/sudo_command_from_user_path.py @@ -0,0 +1,21 @@ +import re +from thefuck.utils import for_app, which, replace_argument + + +def _get_command_name(command): + found = re.findall(r'sudo: (.*): command not found', command.stderr) + if found: + return found[0] + + +@for_app('sudo') +def match(command): + if 'command not found' in command.stderr: + command_name = _get_command_name(command) + return which(command_name) + + +def get_new_command(command): + command_name = _get_command_name(command) + return replace_argument(command.script, command_name, + u'env "PATH=$PATH" {}'.format(command_name)) diff --git a/thefuck/rules/systemctl.py b/thefuck/rules/systemctl.py index 334c783e..499235c5 100644 --- a/thefuck/rules/systemctl.py +++ b/thefuck/rules/systemctl.py @@ -17,6 +17,6 @@ def match(command): @sudo_support def get_new_command(command): - cmd = command.script_parts + cmd = command.script_parts[:] cmd[-1], cmd[-2] = cmd[-2], cmd[-1] return ' '.join(cmd) diff --git a/thefuck/rules/yarn_alias.py b/thefuck/rules/yarn_alias.py new file mode 100644 index 00000000..4c1a584b --- /dev/null +++ b/thefuck/rules/yarn_alias.py @@ -0,0 +1,14 @@ +import re +from thefuck.utils import replace_argument, for_app + + +@for_app('yarn', at_least=1) +def match(command): + return ('Did you mean' in command.stderr) + + +def get_new_command(command): + broken = command.script_parts[1] + fix = re.findall(r'Did you mean `yarn ([^`]*)`', command.stderr)[0] + + return replace_argument(command.script, broken, fix) diff --git a/thefuck/rules/yarn_command_not_found.py b/thefuck/rules/yarn_command_not_found.py new file mode 100644 index 00000000..6d51ff84 --- /dev/null +++ b/thefuck/rules/yarn_command_not_found.py @@ -0,0 +1,31 @@ +import re +from subprocess import Popen, PIPE +from thefuck.utils import for_app, eager, replace_command + +regex = re.compile(r'error Command "(.*)" not found.') + + +@for_app('yarn') +def match(command): + return regex.findall(command.stderr) + + +@eager +def _get_all_tasks(): + proc = Popen(['yarn', '--help'], stdout=PIPE) + should_yield = False + for line in proc.stdout.readlines(): + line = line.decode().strip() + + if 'Commands:' in line: + should_yield = True + continue + + if should_yield and '- ' in line: + yield line.split(' ')[-1] + + +def get_new_command(command): + misspelled_task = regex.findall(command.stderr)[0] + tasks = _get_all_tasks() + return replace_command(command, misspelled_task, tasks) diff --git a/thefuck/shells/bash.py b/thefuck/shells/bash.py index 8f4e0e1c..c5ff5359 100644 --- a/thefuck/shells/bash.py +++ b/thefuck/shells/bash.py @@ -14,7 +14,7 @@ class Bash(Generic): " eval $TF_CMD".format(fuck) if settings.alter_history: - return alias + " && history -s $TF_CMD'" + return alias + "; history -s $TF_CMD'" else: return alias + "'" @@ -44,4 +44,9 @@ class Bash(Generic): config = '~/.bashrc' else: config = 'bash config' - return 'eval $(thefuck --alias)', config + + return { + 'content': 'eval $(thefuck --alias)', + 'path': config, + 'reload': u'source {}'.format(config), + } diff --git a/thefuck/shells/fish.py b/thefuck/shells/fish.py index 34fdf7b2..e20ce29e 100644 --- a/thefuck/shells/fish.py +++ b/thefuck/shells/fish.py @@ -20,8 +20,9 @@ class Fish(Generic): def app_alias(self, fuck): if settings.alter_history: - alter_history = (' history --delete $fucked_up_command\n' - ' history --merge ^ /dev/null\n') + alter_history = (' builtin history delete --exact' + ' --case-sensitive -- $fucked_up_command\n' + ' builtin history merge ^ /dev/null\n') else: alter_history = '' # It is VERY important to have the variables declared WITHIN the alias @@ -66,8 +67,11 @@ class Fish(Generic): return u'; and '.join(commands) def how_to_configure(self): - return (r"eval (thefuck --alias | tr '\n' ';')", - '~/.config/fish/config.fish') + return { + 'content': r"eval (thefuck --alias | tr '\n' ';')", + 'path': '~/.config/fish/config.fish', + 'reload': 'fish', + } def put_to_history(self, command): try: diff --git a/thefuck/shells/generic.py b/thefuck/shells/generic.py index e20d7ec3..a4f0b1e4 100644 --- a/thefuck/shells/generic.py +++ b/thefuck/shells/generic.py @@ -65,9 +65,19 @@ class Generic(object): def split_command(self, command): """Split the command using shell-like syntax.""" + encoded = self.encode_utf8(command) + splitted = shlex.split(encoded) + return self.decode_utf8(splitted) + + def encode_utf8(self, command): if six.PY2: - return [s.decode('utf8') for s in shlex.split(command.encode('utf8'))] - return shlex.split(command) + return command.encode('utf8') + return command + + def decode_utf8(self, command_parts): + if six.PY2: + return [s.decode('utf8') for s in command_parts] + return command_parts def quote(self, s): """Return a shell-escaped version of the string s.""" diff --git a/thefuck/shells/powershell.py b/thefuck/shells/powershell.py index 160a05c4..bf954bf3 100644 --- a/thefuck/shells/powershell.py +++ b/thefuck/shells/powershell.py @@ -3,11 +3,14 @@ from .generic import Generic class Powershell(Generic): def app_alias(self, fuck): - return 'function ' + fuck + ' { \n' \ - ' $fuck = $(thefuck (Get-History -Count 1).CommandLine);\n' \ - ' if (-not [string]::IsNullOrWhiteSpace($fuck)) {\n' \ - ' if ($fuck.StartsWith("echo")) { $fuck = $fuck.Substring(5); }\n' \ - ' else { iex "$fuck"; }\n' \ + return 'function ' + fuck + ' {\n' \ + ' $history = (Get-History -Count 1).CommandLine;\n' \ + ' if (-not [string]::IsNullOrWhiteSpace($history)) {\n' \ + ' $fuck = $(thefuck $history);\n' \ + ' if (-not [string]::IsNullOrWhiteSpace($fuck)) {\n' \ + ' if ($fuck.StartsWith("echo")) { $fuck = $fuck.Substring(5); }\n' \ + ' else { iex "$fuck"; }\n' \ + ' }\n' \ ' }\n' \ '}\n' @@ -15,4 +18,8 @@ class Powershell(Generic): return u' -and '.join('({0})'.format(c) for c in commands) def how_to_configure(self): - return 'iex "thefuck --alias"', '$profile' + return { + 'content': 'iex "thefuck --alias"', + 'path': '$profile', + 'reload': '& $profile', + } diff --git a/thefuck/shells/tcsh.py b/thefuck/shells/tcsh.py index 3f5bc004..9c9ee0d4 100644 --- a/thefuck/shells/tcsh.py +++ b/thefuck/shells/tcsh.py @@ -31,4 +31,8 @@ class Tcsh(Generic): return u'#+{}\n{}\n'.format(int(time()), command_script) def how_to_configure(self): - return 'eval `thefuck --alias`', '~/.tcshrc' + return { + 'content': 'eval `thefuck --alias`', + 'path': '~/.tcshrc', + 'reload': 'tcsh', + } diff --git a/thefuck/shells/zsh.py b/thefuck/shells/zsh.py index e522d6a3..24551462 100644 --- a/thefuck/shells/zsh.py +++ b/thefuck/shells/zsh.py @@ -15,7 +15,7 @@ class Zsh(Generic): " eval $TF_CMD".format(alias_name) if settings.alter_history: - return alias + " && print -s $TF_CMD'" + return alias + " ; test -n \"$TF_CMD\" && print -s $TF_CMD'" else: return alias + "'" @@ -45,4 +45,8 @@ class Zsh(Generic): return '' def how_to_configure(self): - return 'eval $(thefuck --alias)', '~/.zshrc' + return { + 'content': 'eval $(thefuck --alias)', + 'path': '~/.zshrc', + 'reload': 'source ~/.zshrc', + } diff --git a/thefuck/types.py b/thefuck/types.py index a66f3df8..91f69e9c 100644 --- a/thefuck/types.py +++ b/thefuck/types.py @@ -34,7 +34,8 @@ class Command(object): except Exception: logs.debug(u"Can't split command script {} because:\n {}".format( self, sys.exc_info())) - self._script_parts = None + self._script_parts = [] + return self._script_parts def __eq__(self, other): diff --git a/thefuck/ui.py b/thefuck/ui.py index 2a2f84ba..d30f74d9 100644 --- a/thefuck/ui.py +++ b/thefuck/ui.py @@ -12,9 +12,10 @@ def read_actions(): while True: key = get_key() - if key in (const.KEY_UP, 'k'): + # Handle arrows, j/k (qwerty), and n/e (colemak) + if key in (const.KEY_UP, 'k', 'e'): yield const.ACTION_PREVIOUS - elif key in (const.KEY_DOWN, 'j'): + elif key in (const.KEY_DOWN, 'j', 'n'): yield const.ACTION_NEXT elif key in (const.KEY_CTRL_C, 'q'): yield const.ACTION_ABORT diff --git a/thefuck/utils.py b/thefuck/utils.py index b5680351..2bb20a22 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -159,7 +159,7 @@ def is_app(command, *app_names, **kwargs): if kwargs: raise TypeError("got an unexpected keyword argument '{}'".format(kwargs.keys())) - if command.script_parts is not None and len(command.script_parts) > at_least: + if len(command.script_parts) > at_least: return command.script_parts[0] in app_names return False @@ -264,7 +264,7 @@ def get_valid_history_without_current(command): from thefuck.shells import shell history = shell.get_history() tf_alias = get_alias() - executables = get_all_executables() + executables = set(get_all_executables()) return [line for line in _not_corrected(history, tf_alias) if not line.startswith(tf_alias) and not line == command.script and line.split(' ')[0] in executables] diff --git a/tox.ini b/tox.ini index 09a0d9db..71deba95 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py33,py34,py35 +envlist = py27,py33,py34,py35,py36 [testenv] deps = -rrequirements.txt