From 2e6691712cd8f7be818fa443fd9de211591a8fec Mon Sep 17 00:00:00 2001 From: sandroid Date: Sat, 6 Jun 2026 01:41:15 +0200 Subject: [PATCH] feat: add interactive git restore selector Co-Authored-By: Javier Tia --- README.md | 3 ++ bin/git-forgit | 75 +++++++++++++++++++++++++++++++++ completions/_git-forgit | 3 ++ completions/git-forgit.bash | 4 ++ completions/git-forgit.fish | 4 +- conf.d/forgit.plugin.fish | 1 + forgit.plugin.zsh | 6 +++ tests/list-staged-files.test.sh | 35 +++++++++++++++ tests/restore.test.sh | 50 ++++++++++++++++++++++ 9 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/list-staged-files.test.sh create mode 100644 tests/restore.test.sh diff --git a/README.md b/README.md index 9ba45632..6684a05b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ It's **lightweight** and **easy to use**. | `gat` | Interactive `.gitattributes` generator | | `gso` | Interactive `git show` viewer | | `grh` | Interactive `git reset HEAD ` selector | +| `grs` | Interactive `git restore ` selector | | `gcf` | Interactive `git checkout ` selector | | `gcff` | Interactive `git checkout from ` selector | | `gcb` | Interactive `git checkout ` selector | @@ -198,6 +199,7 @@ forgit_diff=gd forgit_show=gso forgit_add=ga forgit_reset_head=grh +forgit_restore=grs forgit_ignore=gi forgit_attributes=gat forgit_checkout_file=gcf @@ -279,6 +281,7 @@ Each forgit command can be customized with dedicated environment variables for g | `gd` | `FORGIT_DIFF_GIT_OPTS` | `FORGIT_DIFF_FZF_OPTS` | | `gso` | `FORGIT_SHOW_GIT_OPTS` | `FORGIT_SHOW_FZF_OPTS` | | `grh` | `FORGIT_RESET_HEAD_GIT_OPTS` | `FORGIT_RESET_HEAD_FZF_OPTS` | +| `grs` | `FORGIT_RESTORE_GIT_OPTS` | `FORGIT_RESTORE_FZF_OPTS` | | `gcf` | `FORGIT_CHECKOUT_FILE_GIT_OPTS` | `FORGIT_CHECKOUT_FILE_FZF_OPTS` | | `gcff` | `FORGIT_SHOW_GIT_OPTS`
`FORGIT_CHECKOUT_FILE_GIT_OPTS` | `FORGIT_CHECKOUT_FILE_FROM_COMMIT_LOG_FZF_OPTS`
`FORGIT_CHECKOUT_FILE_FROM_COMMIT_SHOW_FZF_OPTS` | | `gcb` | `FORGIT_CHECKOUT_BRANCH_GIT_OPTS`
`FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS` | `FORGIT_CHECKOUT_BRANCH_FZF_OPTS` | diff --git a/bin/git-forgit b/bin/git-forgit index 85d94d59..072aafba 100755 --- a/bin/git-forgit +++ b/bin/git-forgit @@ -219,6 +219,12 @@ _forgit_list_files() { git ls-files -z "$@" "$rootdir" | tr '\0' '\n' | uniq } +_forgit_list_staged_files() { + local up + up="$(git rev-parse --show-cdup)" + git diff --name-only --cached -z | tr '\0' '\n' | awk -v up="$up" '{ print up $0 }' +} + # Print changed files in the worktree # # Always includes modified and unmerged files. Includes untracked files when @@ -674,6 +680,74 @@ _forgit_reset_head() { git status --short } +_forgit_restore_preview() { + git diff --color=always "$@" | _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[@]}" "$@" +} + +_forgit_parse_restore_flags() { + local staged worktree preview_arg + staged=false + worktree=false + + for arg in "$@"; do + case "$arg" in + -S | --staged) staged=true ;; + -W | --worktree) worktree=true ;; + esac + done + + if [[ $staged == true && $worktree != true ]]; then + preview_arg=--staged + elif [[ $staged == true && $worktree == true ]]; then + preview_arg=HEAD + fi + + printf '%s\t%s\t%s' "$staged" "$worktree" "$preview_arg" +} + +# git restore selector +_forgit_restore() { + _forgit_inside_work_tree || return 1 + _forgit_contains_non_flags "$@" && { + _forgit_git_restore "$@" + return $? + } + local files opts staged worktree candidates preview_arg + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags "$@") + opts=" + $FORGIT_FZF_DEFAULT_OPTS + -m -0 + --preview=\"$FORGIT preview restore_preview $preview_arg -- {}\" + $FORGIT_RESTORE_FZF_OPTS + " + + candidates=() + if [[ $staged == true ]]; then + while IFS='' read -r file; do + candidates+=("$file") + done < <(_forgit_list_staged_files) + fi + if [[ $worktree == true || $staged != true ]]; then + while IFS='' read -r file; do + candidates+=("$file") + done < <(_forgit_list_files --modified) + fi + + [[ ${#candidates[@]} -eq 0 ]] && echo "Nothing to restore." && return 1 + + files=() + while IFS='' read -r file; do + files+=("$file") + done < <(printf '%s\n' "${candidates[@]}" | sort -u | FZF_DEFAULT_OPTS="$opts" fzf) + [[ ${#files[@]} -gt 0 ]] && _forgit_git_restore "$@" -- "${files[@]}" +} + _forgit_stash_show_preview() { local stash stash=$(echo "$1" | _forgit_extract_stash_name) @@ -1737,6 +1811,7 @@ PUBLIC_COMMANDS=( "reflog" "rebase" "reset_head" + "restore" "revert_commit" "show" "stash_show" diff --git a/completions/_git-forgit b/completions/_git-forgit index 08bb79b7..cc787269 100644 --- a/completions/_git-forgit +++ b/completions/_git-forgit @@ -85,6 +85,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' 'show:git show viewer' @@ -117,6 +118,7 @@ _git-forgit() { reflog) _git-forgit-reflog ;; rebase) _git-rebase ;; reset_head) _git-staged ;; + restore) _git-restore ;; revert_commit) __git_recent_commits ;; reword) _git-log ;; squash) _git-log ;; @@ -151,6 +153,7 @@ compdef _git-log forgit::log compdef _git-reflog forgit::reflog compdef _git-rebase forgit::rebase compdef _git-staged forgit::reset::head +compdef _git-restore forgit::restore 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 ff1a3ef9..b1d84fa4 100755 --- a/completions/git-forgit.bash +++ b/completions/git-forgit.bash @@ -77,6 +77,7 @@ _git_forgit() { reflog rebase reset_head + restore revert_commit reword show @@ -114,6 +115,7 @@ _git_forgit() { reflog) _git_reflog ;; rebase) _git_rebase ;; reset_head) _git_reset ;; + restore) _git_restore ;; revert_commit) _git_revert ;; reword) _git_log ;; show) _git_show ;; @@ -156,6 +158,7 @@ if [[ $(type -t forgit::add) == function ]]; 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_restore __git_complete forgit::revert::commit _git_revert __git_complete forgit::reword _git_log __git_complete forgit::show _git_show @@ -183,6 +186,7 @@ if [[ $(type -t forgit::add) == function ]]; 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_restore __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 0aae12f9..40dfb75d 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_file_from_commit \ 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 worktree worktree_add worktree_delete + restore revert_commit reword squash stash_show stash_push switch_branch worktree worktree_add worktree_delete if contains -- $subcmd (commandline -opc) return 1 end @@ -44,6 +44,7 @@ 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 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' @@ -70,6 +71,7 @@ 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 "(complete -C 'git restore ')" 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 2fe5b09e..4b30d25f 100644 --- a/conf.d/forgit.plugin.fish +++ b/conf.d/forgit.plugin.fish @@ -50,6 +50,7 @@ end 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_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 f65531c4..b35ec18d 100755 --- a/forgit.plugin.zsh +++ b/forgit.plugin.zsh @@ -72,6 +72,10 @@ forgit::reset::head() { "$FORGIT" reset_head "$@" } +forgit::restore() { + "$FORGIT" restore "$@" +} + forgit::stash::show() { "$FORGIT" stash_show "$@" } @@ -195,6 +199,7 @@ 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_log="${forgit_log:-glo}" builtin export forgit_reflog="${forgit_reflog:-grl}" builtin export forgit_diff="${forgit_diff:-gd}" @@ -224,6 +229,7 @@ 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_log}"='forgit::log' builtin alias "${forgit_reflog}"='forgit::reflog' builtin alias "${forgit_diff}"='forgit::diff' diff --git a/tests/list-staged-files.test.sh b/tests/list-staged-files.test.sh new file mode 100644 index 00000000..71285c5c --- /dev/null +++ b/tests/list-staged-files.test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + source bin/git-forgit + + # Ignore global git config files + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL=/dev/null + + # Create a temporary git repository for testing + cd "$(bashunit::temp_dir)" || return 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test User" + + touch untracked.txt modified.txt staged.txt + git add modified.txt + git commit -q -m "modified" + echo modified >>modified.txt + git add staged.txt +} + +function test_list_staged_files_only_lists_staged() { + output=$(_forgit_list_staged_files) + + assert_same "$output" "staged.txt" +} + +function test_list_staged_files_shows_relative_paths() { + mkdir subdirectory + cd subdirectory || return 1 + + output=$(_forgit_list_staged_files) + assert_same "$output" "../staged.txt" +} diff --git a/tests/restore.test.sh b/tests/restore.test.sh new file mode 100644 index 00000000..f5d2cc40 --- /dev/null +++ b/tests/restore.test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + source bin/git-forgit + + # Ignore global git config files + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL=/dev/null + + # Create a temporary git repository for testing + cd "$(bashunit::temp_dir)" || return 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test User" +} + +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_parse_restore_args() { + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags --staged) + + assert_same "$staged" true + assert_same "$worktree" false + assert_same "$preview_arg" --staged + + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags --worktree) + assert_same "$staged" false + assert_same "$worktree" true + assert_same "$preview_arg" '' + + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags --worktree --staged) + assert_same "$staged" true + assert_same "$worktree" true + assert_same "$preview_arg" HEAD + + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags -W -S) + assert_same "$staged" true + assert_same "$worktree" true + assert_same "$preview_arg" HEAD + + # other flags do not change the outcome + IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags -W -S -U --unknown-flag) + assert_same "$staged" true + assert_same "$worktree" true + assert_same "$preview_arg" HEAD +}