feat: add interactive git restore selector (#537)

Co-authored-by: Javier Tia <floss@jetm.me>
This commit is contained in:
sandroid 2026-06-16 21:16:26 +02:00 committed by GitHub
parent e789afed64
commit 3dbcacd3f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 180 additions and 1 deletions

View file

@ -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 <file>` selector |
| `grs` | Interactive `git restore <file>` selector |
| `gcf` | Interactive `git checkout <file>` selector |
| `gcff` | Interactive `git checkout <file> from <commit>` selector |
| `gcb` | Interactive `git checkout <branch>` 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`<br>`FORGIT_CHECKOUT_FILE_GIT_OPTS` | `FORGIT_CHECKOUT_FILE_FROM_COMMIT_LOG_FZF_OPTS`<br>`FORGIT_CHECKOUT_FILE_FROM_COMMIT_SHOW_FZF_OPTS` |
| `gcb` | `FORGIT_CHECKOUT_BRANCH_GIT_OPTS`<br>`FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS` | `FORGIT_CHECKOUT_BRANCH_FZF_OPTS` |

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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 ')"

View file

@ -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

View file

@ -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'

View file

@ -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"
}

50
tests/restore.test.sh Normal file
View file

@ -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
}