diff --git a/LICENSE b/LICENSE index e508978a..ab428e27 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright © 2017-2021 Wenxuan Zhang +Copyright © 2017-2026 Wenxuan Zhang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal diff --git a/README.md b/README.md index f888f0b4..5158b004 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,15 @@ If you're having issues after updating, and commands such as `forgit::add` or al - **Interactive `git reset HEAD ` selector** (`grh`) +- **Interactive `git restore ` selector** (`grs`) + +- **Interactive `git discard ` selector** (`gdc`) + + Combines `git reset HEAD` (unstage) and `git restore` (revert) into a single operation, + fully discarding all changes for selected files back to their committed state. + + > **Warning:** This permanently discards uncommitted changes with no way to recover them. + - **Interactive `git checkout ` selector** (`gcf`) - **Interactive `git checkout ` selector** (`gcb`) @@ -222,6 +231,8 @@ forgit_diff=gd forgit_show=gso forgit_add=ga forgit_reset_head=grh +forgit_restore=grs +forgit_discard=gdc forgit_ignore=gi forgit_attributes=gat forgit_checkout_file=gcf @@ -286,6 +297,8 @@ These are passed to the according `git` calls. | `gd` | `FORGIT_DIFF_GIT_OPTS` | | `gso` | `FORGIT_SHOW_GIT_OPTS` | | `grh` | `FORGIT_RESET_HEAD_GIT_OPTS` | +| `grs` | `FORGIT_RESTORE_GIT_OPTS` | +| `gdc` | `FORGIT_DISCARD_GIT_OPTS` | | `gcf` | `FORGIT_CHECKOUT_FILE_GIT_OPTS` | | `gcb` | `FORGIT_CHECKOUT_BRANCH_GIT_OPTS`, `FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS` | | `gsw` | `FORGIT_SWITCH_BRANCH_GIT_OPTS` | @@ -346,6 +359,8 @@ Customizing fzf options for each command individually is also supported: | `gd` | `FORGIT_DIFF_FZF_OPTS` | | `gso` | `FORGIT_SHOW_FZF_OPTS` | | `grh` | `FORGIT_RESET_HEAD_FZF_OPTS` | +| `grs` | `FORGIT_RESTORE_FZF_OPTS` | +| `gdc` | `FORGIT_DISCARD_FZF_OPTS` | | `gcf` | `FORGIT_CHECKOUT_FILE_FZF_OPTS` | | `gcb` | `FORGIT_CHECKOUT_BRANCH_FZF_OPTS` | | `gsw` | `FORGIT_SWITCH_BRANCH_FZF_OPTS` | diff --git a/bin/git-forgit b/bin/git-forgit index f54845f2..11fb5cf0 100755 --- a/bin/git-forgit +++ b/bin/git-forgit @@ -522,6 +522,74 @@ _forgit_reset_head() { git status --short } +_forgit_restore_preview() { + git diff --color=always -- "$1" | _forgit_pager diff +} + +_forgit_git_restore() { + _forgit_restore_git_opts=() + _forgit_parse_array _forgit_restore_git_opts "$FORGIT_RESTORE_GIT_OPTS" + git restore "${_forgit_restore_git_opts[@]}" "$@" +} + +# git restore selector +_forgit_restore() { + _forgit_inside_work_tree || return 1 + local files opts + _forgit_contains_non_flags "$@" && { + _forgit_git_restore "$@" + return $? + } + [[ $(_forgit_list_files --modified | wc -l) -eq 0 ]] && echo 'Nothing to restore.' && return 1 + opts=" + $FORGIT_FZF_DEFAULT_OPTS + -m -0 + --preview=\"$FORGIT restore_preview {}\" + $FORGIT_RESTORE_FZF_OPTS + " + files=() + while IFS='' read -r file; do + files+=("$file") + done < <(_forgit_list_files --modified | + FZF_DEFAULT_OPTS="$opts" fzf) + [[ "${#files[@]}" -gt 0 ]] && _forgit_git_restore "$@" "${files[@]}" +} + +_forgit_discard_preview() { + git diff --color=always HEAD -- "$1" | _forgit_pager diff +} + +_forgit_git_discard() { + _forgit_discard_git_opts=() + _forgit_parse_array _forgit_discard_git_opts "$FORGIT_DISCARD_GIT_OPTS" + git reset -q HEAD "${_forgit_discard_git_opts[@]}" -- "$@" && git restore "${_forgit_discard_git_opts[@]}" "$@" +} + +# git discard (unstage + restore) selector +_forgit_discard() { + _forgit_inside_work_tree || return 1 + local files opts + _forgit_contains_non_flags "$@" && { + _forgit_git_discard "$@" + return $? + } + local changed + changed=$(git diff --name-only HEAD 2>/dev/null) + [[ -z "$changed" ]] && echo 'Nothing to discard.' && return 1 + opts=" + $FORGIT_FZF_DEFAULT_OPTS + -m -0 + --preview=\"$FORGIT discard_preview {}\" + $FORGIT_DISCARD_FZF_OPTS + " + files=() + while IFS='' read -r file; do + files+=("$file") + done < <(echo "$changed" | + FZF_DEFAULT_OPTS="$opts" fzf) + [[ "${#files[@]}" -gt 0 ]] && _forgit_git_discard "${files[@]}" +} + _forgit_stash_show_preview() { local stash stash=$(echo "$1" | _forgit_extract_stash_name) @@ -1303,6 +1371,8 @@ PUBLIC_COMMANDS=( "reflog" "rebase" "reset_head" + "restore" + "discard" "revert_commit" "show" "stash_show" @@ -1324,6 +1394,8 @@ PRIVATE_COMMANDS=( "path_preview" "revert_preview" "reset_head_preview" + "restore_preview" + "discard_preview" "show_enter" "show_preview" "stash_push_preview" diff --git a/completions/_git-forgit b/completions/_git-forgit index ee80c88b..38c23426 100644 --- a/completions/_git-forgit +++ b/completions/_git-forgit @@ -74,6 +74,7 @@ _git-forgit() { 'reflog:git reflog viewer' 'rebase:git rebase' 'reset_head:git reset HEAD (unstage) selector' + 'restore:git restore file selector' 'revert_commit:git revert commit selector' 'reword:git fixup=reword' 'squash:git squash' @@ -97,6 +98,7 @@ _git-forgit() { reflog) _git-forgit-reflog ;; rebase) _git-rebase ;; reset_head) _git-staged ;; + restore) _git-checkout-file ;; revert_commit) __git_recent_commits ;; reword) _git-log ;; squash) _git-log ;; @@ -126,6 +128,8 @@ compdef _git-log forgit::log compdef _git-reflog forgit::reflog compdef _git-rebase forgit::rebase compdef _git-staged forgit::reset::head +compdef _git-checkout-file forgit::restore +compdef _git-checkout-file forgit::discard compdef __git_recent_commits forgit::revert::commit compdef _git-log forgit::reword compdef _git-log forgit::squash diff --git a/completions/git-forgit.bash b/completions/git-forgit.bash index 7a508ce7..797cacbc 100755 --- a/completions/git-forgit.bash +++ b/completions/git-forgit.bash @@ -74,6 +74,7 @@ _git_forgit() reflog rebase reset_head + restore revert_commit reword show @@ -103,6 +104,7 @@ _git_forgit() reflog) _git_reflog ;; rebase) _git_rebase ;; reset_head) _git_reset ;; + restore) _git_checkout_file ;; revert_commit) _git_revert ;; reword) _git_log ;; show) _git_show ;; @@ -141,6 +143,8 @@ then __git_complete forgit::reflog _git_reflog __git_complete forgit::rebase _git_rebase __git_complete forgit::reset::head _git_reset + __git_complete forgit::restore _git_checkout_file +__git_complete forgit::discard _git_checkout_file __git_complete forgit::revert::commit _git_revert __git_complete forgit::reword _git_log __git_complete forgit::show _git_show @@ -164,6 +168,8 @@ then __git_complete "${forgit_reflog}" _git_reflog __git_complete "${forgit_rebase}" _git_rebase __git_complete "${forgit_reset_head}" _git_reset + __git_complete "${forgit_restore}" _git_checkout_file +__git_complete "${forgit_discard}" _git_checkout_file __git_complete "${forgit_revert_commit}" _git_revert __git_complete "${forgit_reword}" _git_log __git_complete "${forgit_show}" _git_show diff --git a/completions/git-forgit.fish b/completions/git-forgit.fish index 48f995cf..d6e0b052 100644 --- a/completions/git-forgit.fish +++ b/completions/git-forgit.fish @@ -8,7 +8,7 @@ function __fish_forgit_needs_subcommand for subcmd in add blame branch_delete checkout_branch checkout_commit checkout_file checkout_tag \ cherry_pick cherry_pick_from_branch clean diff fixup ignore log reflog rebase reset_head \ - revert_commit reword squash stash_show stash_push switch_branch + restore revert_commit reword squash stash_show stash_push switch_branch if contains -- $subcmd (commandline -opc) return 1 end @@ -39,6 +39,8 @@ complete -c git-forgit -n __fish_forgit_needs_subcommand -a log -d 'git commit v complete -c git-forgit -n __fish_forgit_needs_subcommand -a reflog -d 'git reflog viewer' complete -c git-forgit -n __fish_forgit_needs_subcommand -a rebase -d 'git rebase' complete -c git-forgit -n __fish_forgit_needs_subcommand -a reset_head -d 'git reset HEAD (unstage) selector' +complete -c git-forgit -n __fish_forgit_needs_subcommand -a restore -d 'git restore file selector' +complete -c git-forgit -n __fish_forgit_needs_subcommand -a discard -d 'git discard (unstage + restore) selector' complete -c git-forgit -n __fish_forgit_needs_subcommand -a revert_commit -d 'git revert commit selector' complete -c git-forgit -n __fish_forgit_needs_subcommand -a reword -d 'git fixup=reword' complete -c git-forgit -n __fish_forgit_needs_subcommand -a show -d 'git show viewer' @@ -61,6 +63,8 @@ complete -c git-forgit -n '__fish_seen_subcommand_from log' -a "(complete -C 'gi complete -c git-forgit -n '__fish_seen_subcommand_from reflog' -a "(complete -C 'git reflog ')" complete -c git-forgit -n '__fish_seen_subcommand_from rebase' -a "(complete -C 'git rebase ')" complete -c git-forgit -n '__fish_seen_subcommand_from reset_head' -a "(__fish_git_files all-staged)" +complete -c git-forgit -n '__fish_seen_subcommand_from restore' -a "(__fish_git_files modified)" +complete -c git-forgit -n '__fish_seen_subcommand_from discard' -a "(__fish_git_files modified)" complete -c git-forgit -n '__fish_seen_subcommand_from revert_commit' -a "(__fish_git_commits)" complete -c git-forgit -n '__fish_seen_subcommand_from reword' -a "(complete -C 'git log ')" complete -c git-forgit -n '__fish_seen_subcommand_from show' -a "(complete -C 'git show ')" diff --git a/conf.d/forgit.plugin.fish b/conf.d/forgit.plugin.fish index 2ffd4299..5ac3ded9 100644 --- a/conf.d/forgit.plugin.fish +++ b/conf.d/forgit.plugin.fish @@ -34,6 +34,8 @@ alias git-forgit "$FORGIT" if test -z "$FORGIT_NO_ALIASES" abbr -a -- (string collect $forgit_add; or string collect "ga") git-forgit add abbr -a -- (string collect $forgit_reset_head; or string collect "grh") git-forgit reset_head + abbr -a -- (string collect $forgit_restore; or string collect "grs") git-forgit restore + abbr -a -- (string collect $forgit_discard; or string collect "gdc") git-forgit discard abbr -a -- (string collect $forgit_log; or string collect "glo") git-forgit log abbr -a -- (string collect $forgit_reflog; or string collect "grl") git-forgit reflog abbr -a -- (string collect $forgit_diff; or string collect "gd") git-forgit diff diff --git a/forgit.plugin.zsh b/forgit.plugin.zsh index 012c354f..68675e1c 100755 --- a/forgit.plugin.zsh +++ b/forgit.plugin.zsh @@ -68,6 +68,14 @@ forgit::reset::head() { "$FORGIT" reset_head "$@" } +forgit::restore() { + "$FORGIT" restore "$@" +} + +forgit::discard() { + "$FORGIT" discard "$@" +} + forgit::stash::show() { "$FORGIT" stash_show "$@" } @@ -166,6 +174,8 @@ if [[ -z "$FORGIT_NO_ALIASES" ]]; then builtin export forgit_add="${forgit_add:-ga}" builtin export forgit_reset_head="${forgit_reset_head:-grh}" + builtin export forgit_restore="${forgit_restore:-grs}" + builtin export forgit_discard="${forgit_discard:-gdc}" builtin export forgit_log="${forgit_log:-glo}" builtin export forgit_reflog="${forgit_reflog:-grl}" builtin export forgit_diff="${forgit_diff:-gd}" @@ -191,6 +201,8 @@ if [[ -z "$FORGIT_NO_ALIASES" ]]; then builtin alias "${forgit_add}"='forgit::add' builtin alias "${forgit_reset_head}"='forgit::reset::head' + builtin alias "${forgit_restore}"='forgit::restore' + builtin alias "${forgit_discard}"='forgit::discard' builtin alias "${forgit_log}"='forgit::log' builtin alias "${forgit_reflog}"='forgit::reflog' builtin alias "${forgit_diff}"='forgit::diff' diff --git a/tests/discard.test.sh b/tests/discard.test.sh new file mode 100644 index 00000000..8931332e --- /dev/null +++ b/tests/discard.test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + source bin/git-forgit + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL=/dev/null + cd "$(bashunit::temp_dir)" || return 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test User" + echo "initial" >README.md + git add README.md + git commit -q -m "Initial commit" +} + +function test_discard_shows_message_when_no_changes() { + output=$(_forgit_discard 2>&1) + assert_general_error + assert_same "Nothing to discard." "$output" +} + +function test_discard_reverts_unstaged_changes() { + echo "original" >tracked.txt + git add tracked.txt + git commit -q -m "Add tracked file" + echo "modified" >tracked.txt + _forgit_git_discard tracked.txt + assert_same "original" "$(cat tracked.txt)" +} + +function test_discard_reverts_staged_changes() { + echo "original" >staged.txt + git add staged.txt + git commit -q -m "Add staged file" + echo "staged change" >staged.txt + git add staged.txt + _forgit_git_discard staged.txt + assert_same "original" "$(cat staged.txt)" + # Verify file is no longer staged + assert_same "" "$(git diff --staged --name-only)" +} + +function test_discard_reverts_mixed_staged_and_unstaged_changes() { + echo "committed" >mixed.txt + git add mixed.txt + git commit -q -m "Add mixed file" + echo "staged change" >mixed.txt + git add mixed.txt + echo "unstaged change" >mixed.txt + _forgit_git_discard mixed.txt + assert_same "committed" "$(cat mixed.txt)" +} + +function test_discard_with_committed_rename() { + echo "rename content" >before-rename.txt + git add before-rename.txt + git commit -q -m "Add file for rename test" + git mv before-rename.txt after-rename.txt + git commit -q -m "Rename file" + echo "modified after rename" >after-rename.txt + _forgit_git_discard after-rename.txt + assert_same "rename content" "$(cat after-rename.txt)" +} + +function test_discard_passes_through_arguments_when_non_flags_provided() { + echo "original" >passthrough.txt + git add passthrough.txt + git commit -q -m "Add passthrough file" + echo "modified" >passthrough.txt + git add passthrough.txt + _forgit_discard passthrough.txt + assert_same "original" "$(cat passthrough.txt)" +} diff --git a/tests/restore.test.sh b/tests/restore.test.sh new file mode 100644 index 00000000..69f45284 --- /dev/null +++ b/tests/restore.test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + source bin/git-forgit + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL=/dev/null + cd "$(bashunit::temp_dir)" || return 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test User" + echo "initial" >README.md + git add README.md + git commit -q -m "Initial commit" +} + +function test_restore_shows_message_when_no_modified_files() { + output=$(_forgit_restore 2>&1) + assert_general_error + assert_same "Nothing to restore." "$output" +} + +function test_restore_reverts_modified_file_to_committed_state() { + echo "original content" >tracked.txt + git add tracked.txt + git commit -q -m "Add tracked file" + echo "modified content" >tracked.txt + _forgit_git_restore tracked.txt + assert_same "original content" "$(cat tracked.txt)" +} + +function test_restore_does_not_affect_untracked_files() { + echo "untracked content" >untracked.txt + _forgit_git_restore untracked.txt 2>/dev/null || true + assert_file_exists untracked.txt + assert_same "untracked content" "$(cat untracked.txt)" +} + +function test_restore_only_reverts_unstaged_changes_when_file_has_staged_and_unstaged() { + echo "committed" >mixed.txt + git add mixed.txt + git commit -q -m "Add mixed file" + echo "staged change" >mixed.txt + git add mixed.txt + echo "unstaged change" >mixed.txt + _forgit_git_restore mixed.txt + assert_same "staged change" "$(cat mixed.txt)" +} + +function test_restore_with_renamed_file() { + echo "rename content" >before-rename.txt + git add before-rename.txt + git commit -q -m "Add file for rename test" + git mv before-rename.txt after-rename.txt + git commit -q -m "Rename file" + echo "modified after rename" >after-rename.txt + _forgit_git_restore after-rename.txt + assert_same "rename content" "$(cat after-rename.txt)" +} + +function test_restore_passes_through_arguments_when_non_flags_provided() { + echo "original" >passthrough.txt + git add passthrough.txt + git commit -q -m "Add passthrough file" + echo "modified" >passthrough.txt + _forgit_restore passthrough.txt + assert_same "original" "$(cat passthrough.txt)" +}