mirror of
https://github.com/wfxr/forgit.git
synced 2026-09-10 07:16:23 -04:00
fix: preserve add paths across special filenames (#506)
Keep `forgit add` on the porcelain `git status --porcelain -zs` path so filenames containing backslashes continue to work, while restoring the behaviors that regressed when we moved away from Git's cwd-relative output. This change makes the status picker emit a display label and a hidden absolute-path payload separately. That lets the UI keep showing intuitive cwd-relative paths, while preview, edit, and add actions operate on the real path instead of reparsing the rendered status line. As a result, untracked files, subdirectory workflows, and special filenames now share one consistent path flow. It also restores the old-Git fallback for plain `?? path` output before status filtering, raises the required fzf version for `--accept-nth`, and adds regression coverage for backslashes, spaces, tabs, subdirectory entries, sibling directories, and logical symlink paths. We explored a few alternatives before landing here. Keeping a single human-readable line and reparsing it downstream remained too fragile for quoted paths and backslashes. Shell-only display-path rewriting worked for some cases but stayed brittle across logical vs physical paths and still failed in macOS CI. A per-path `realpath` approach would have been easier to read, but GNU-style relative-path support is not portable across the platforms we test and would add one external process per file in a hot path. The final tradeoff keeps the pipeline batch-oriented and portable by doing the path normalization once in a single helper step, even though that is less lightweight than the earlier shell-only versions. That complexity is justified here because it fixes the old-Git untracked regression, preserves correct preview/add behavior from subdirectories, and avoids reintroducing long-standing filename parsing bugs.
This commit is contained in:
parent
d4bece432b
commit
17110bfc61
111
bin/git-forgit
111
bin/git-forgit
|
|
@ -12,7 +12,10 @@
|
|||
# This gives users the choice to set aliases inside of their git config instead
|
||||
# of their shell config if they prefer.
|
||||
|
||||
REQUIRED_FZF_VERSION="0.49.0"
|
||||
REQUIRED_FZF_VERSION="0.60.0"
|
||||
|
||||
# forgit-fzf separator used between the visible label and hidden payload.
|
||||
_ffsep=$'\x1f\x1e'
|
||||
|
||||
FORGIT_FZF_DEFAULT_OPTS="
|
||||
$FZF_DEFAULT_OPTS
|
||||
|
|
@ -217,17 +220,91 @@ _forgit_list_files() {
|
|||
#
|
||||
# Always includes modified and unmerged files. Includes untracked files when
|
||||
# status.showUntrackedFiles is true or unset. Never includes staged files.
|
||||
#
|
||||
# The output is formatted for `forgit add` and contains two fields separated by
|
||||
# `$_ffsep`:
|
||||
# 1. the human-readable status line shown in fzf
|
||||
# 2. the absolute path payload used by preview/edit/add actions
|
||||
# fzf only shows field 1 and uses field 2 for actions.
|
||||
#
|
||||
# Keeping the display text separate from the payload avoids reparsing quoted
|
||||
# `git status` output, which breaks for filenames containing backslashes.
|
||||
_forgit_worktree_changes() {
|
||||
local changed unmerged untracked show_untracked
|
||||
local changed reset rootdir show_untracked unmerged untracked
|
||||
changed=$(git config --get-color color.status.changed red)
|
||||
unmerged=$(git config --get-color color.status.unmerged red)
|
||||
untracked=$(git config --get-color color.status.untracked red)
|
||||
reset=$(git config --get-color '' reset)
|
||||
show_untracked=$(git config status.showUntrackedFiles)
|
||||
rootdir=$(git rev-parse --show-toplevel)
|
||||
|
||||
git -c color.status=always -c status.relativePaths=true status --porcelain -zs --untracked="${show_untracked:-all}" |
|
||||
git -c color.status=always status --porcelain -zs --untracked="${show_untracked:-all}" |
|
||||
tr '\0' '\n' |
|
||||
_forgit_restore_untracked_color "$untracked" "$reset" |
|
||||
grep -F -e "$changed" -e "$unmerged" -e "$untracked" |
|
||||
sed -E 's/^(..[^[:space:]]*)[[:space:]]+(.*)$/[\1] \2/'
|
||||
_forgit_build_status_entries "$rootdir"
|
||||
}
|
||||
|
||||
# Normalize plain `?? path` rows from older Git versions before the add-list
|
||||
# color filter runs.
|
||||
#
|
||||
# Input:
|
||||
# stdin - one status line per entry
|
||||
# $1 untracked - color sequence to apply to the `??` marker
|
||||
# $2 reset - reset sequence appended after the colored marker
|
||||
#
|
||||
# Output:
|
||||
# Writes the input lines back to stdout, but rewrites plain `?? path` rows as
|
||||
# colored `??` rows so untracked entries remain visible after filtering.
|
||||
_forgit_restore_untracked_color() {
|
||||
local reset untracked
|
||||
untracked=$1
|
||||
reset=$2
|
||||
|
||||
awk -v untracked="$untracked" -v reset="$reset" '
|
||||
/^\?\? / { sub(/^\?\? /, untracked "??" reset " ") }
|
||||
{ print }
|
||||
'
|
||||
}
|
||||
|
||||
# Build fzf-friendly entries from colored porcelain status lines.
|
||||
#
|
||||
# Input:
|
||||
# stdin - colored porcelain `git status --porcelain -z` rows, converted
|
||||
# to one line per entry
|
||||
# $1 rootdir - absolute repo root used to build the hidden payload
|
||||
#
|
||||
# Output:
|
||||
# Writes one line per entry with two `$_ffsep`-separated fields:
|
||||
# 1. the human-readable status line shown in fzf
|
||||
# 2. the absolute-path payload used by preview/edit/add actions
|
||||
_forgit_build_status_entries() {
|
||||
local cwd rootdir
|
||||
rootdir=$1
|
||||
cwd=$(pwd -P)
|
||||
|
||||
# Use a single Perl process so path normalization stays portable while the
|
||||
# full add-list transformation still runs as one batch pipeline stage.
|
||||
perl -MCwd=realpath -MFile::Spec -e '
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
my ($rootdir, $cwd, $separator) = @ARGV;
|
||||
my $normalized_rootdir = realpath($rootdir);
|
||||
my $normalized_cwd = realpath($cwd);
|
||||
|
||||
while (my $line = <STDIN>) {
|
||||
chomp $line;
|
||||
next unless $line =~ /^(..[^[:space:]]*)( )(.*)$/;
|
||||
|
||||
my ($status, $repo_path) = ($1, $3);
|
||||
my $absolute_path = "$normalized_rootdir/$repo_path";
|
||||
my $display_path = File::Spec->abs2rel(realpath($absolute_path) // $absolute_path, $normalized_cwd);
|
||||
$display_path = "." if $display_path eq q{};
|
||||
|
||||
print "[$status] ${display_path}${separator}${normalized_rootdir}/${repo_path}\n";
|
||||
}
|
||||
' "$rootdir" "$cwd" "$_ffsep"
|
||||
}
|
||||
|
||||
_forgit_is_submodule() {
|
||||
|
|
@ -496,7 +573,8 @@ _forgit_show() {
|
|||
}
|
||||
|
||||
_forgit_add_preview() {
|
||||
file=$(echo "$1" | _forgit_get_single_file_from_add_line)
|
||||
local file
|
||||
file=$1
|
||||
# $file can be a directory when status.showUntrackedFiles is set to 'normal'
|
||||
# When this is the case and the directory is not a submodule show the content of the directory and return
|
||||
if [[ -d "$file" ]] && ! _forgit_is_submodule "$file"; then
|
||||
|
|
@ -516,17 +594,9 @@ _forgit_git_add() {
|
|||
git add "${_forgit_add_git_opts[@]}" "$@"
|
||||
}
|
||||
|
||||
_forgit_get_single_file_from_add_line() {
|
||||
# NOTE: paths listed by 'git status -su' mixed with quoted and unquoted style
|
||||
# remove indicators | remove original path for rename case | remove surrounding quotes
|
||||
sed 's/^.*] //' |
|
||||
sed 's/.* -> //' |
|
||||
sed -e 's/^\"//' -e 's/\"$//'
|
||||
}
|
||||
|
||||
_forgit_edit_add_file() {
|
||||
local input_line=$1
|
||||
filename=$(echo "$input_line" | _forgit_get_single_file_from_add_line)
|
||||
local filename
|
||||
filename=$1
|
||||
$EDITOR "$filename" >/dev/tty </dev/tty
|
||||
}
|
||||
|
||||
|
|
@ -539,15 +609,18 @@ _forgit_add() {
|
|||
|
||||
opts="
|
||||
$FORGIT_FZF_DEFAULT_OPTS
|
||||
-0 -m --nth 2..,..
|
||||
--preview=\"$FORGIT preview add_preview {}\"
|
||||
--bind=\"alt-e:execute($FORGIT edit_add_file {})+refresh-preview\"
|
||||
--delimiter=$_ffsep
|
||||
# Show only the formatted label in fzf, but return the hidden absolute
|
||||
# path payload so downstream actions never need to parse status lines.
|
||||
-0 -m --with-nth=1 --accept-nth=2
|
||||
--preview=\"$FORGIT preview add_preview {2}\"
|
||||
--bind=\"alt-e:execute($FORGIT edit_add_file {2})+refresh-preview\"
|
||||
$FORGIT_ADD_FZF_OPTS
|
||||
"
|
||||
files=()
|
||||
while IFS='' read -r file; do
|
||||
files+=("$file")
|
||||
done < <(_forgit_worktree_changes | FZF_DEFAULT_OPTS="$opts" fzf | _forgit_get_single_file_from_add_line)
|
||||
done < <(_forgit_worktree_changes | FZF_DEFAULT_OPTS="$opts" fzf)
|
||||
[[ "${#files[@]}" -gt 0 ]] && _forgit_git_add "$@" "${files[@]}" && git status -s && return
|
||||
echo 'Nothing to add.'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
function set_up_before_script() {
|
||||
source bin/git-forgit
|
||||
}
|
||||
|
||||
function test_exit_when_fzf_is_not_installed() {
|
||||
# Error code 127 = "command not found"
|
||||
bashunit::mock fzf "return 127"
|
||||
|
|
@ -17,7 +21,7 @@ function test_exit_when_fzf_version_is_below_required_version() {
|
|||
output=$(bin/git-forgit)
|
||||
|
||||
assert_general_error
|
||||
assert_contains "fzf version 0.49.0 or higher is required" "$output"
|
||||
assert_contains "fzf version $REQUIRED_FZF_VERSION or higher is required" "$output"
|
||||
}
|
||||
|
||||
function fzf_versions_below_required_version() {
|
||||
|
|
@ -38,8 +42,8 @@ function test_pass_when_fzf_version_satisfies_required_version() {
|
|||
}
|
||||
|
||||
function fzf_versions_satisfying_required_version() {
|
||||
echo "0.49.0"
|
||||
echo "0.49.1"
|
||||
echo "0.60.0"
|
||||
echo "0.60.1"
|
||||
echo "0.80.0"
|
||||
echo "1.1.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
FORGIT_REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
function set_up_before_script() {
|
||||
source bin/git-forgit
|
||||
|
||||
|
|
@ -65,3 +67,138 @@
|
|||
|
||||
assert_contains 'untracked_with_\backslash' "$output"
|
||||
}
|
||||
|
||||
function test_forgit_build_status_entries_uses_cwd_relative_display_paths_within_subdirectories() {
|
||||
local output
|
||||
|
||||
mkdir -p dir
|
||||
touch dir/file.txt
|
||||
cd dir || return 1
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "file.txt" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_build_status_entries_prefixes_parent_paths_for_entries_outside_cwd() {
|
||||
local output
|
||||
|
||||
mkdir -p dir other
|
||||
touch other/file.txt
|
||||
cd dir || return 1
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "../other/file.txt" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_build_status_entries_keeps_repo_relative_paths_at_repo_root() {
|
||||
local output
|
||||
|
||||
mkdir -p dir
|
||||
touch dir/file.txt
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "dir/file.txt" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_build_status_entries_uses_cwd_relative_display_paths_for_sibling_entries() {
|
||||
local output
|
||||
|
||||
mkdir -p dir1 dir2
|
||||
touch dir1/file.txt
|
||||
cd dir2 || return 1
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "../dir1/file.txt" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_build_status_entries_uses_cwd_relative_display_paths_from_logical_symlink_paths() {
|
||||
local output sandbox
|
||||
|
||||
sandbox=$(bashunit::temp_dir)
|
||||
mkdir -p "$sandbox/real/dir1" "$sandbox/real/dir2"
|
||||
(
|
||||
cd "$sandbox/real" || exit 1
|
||||
git init --quiet
|
||||
git config user.name "Test User"
|
||||
git config user.email "test@example.com"
|
||||
) || return 1
|
||||
ln -s "$sandbox/real" "$sandbox/link"
|
||||
cd "$sandbox/link/dir2" || return 1
|
||||
touch ../dir1/file.txt
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "../dir1/file.txt" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_worktree_changes_emits_absolute_payloads_for_subdir_entries() {
|
||||
local output rootdir
|
||||
|
||||
mkdir dir
|
||||
touch 'dir/with_\backslash'
|
||||
cd dir || return 1
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
rootdir=$(git rev-parse --show-toplevel)
|
||||
|
||||
assert_contains $'with_\\backslash'"$_ffsep""$rootdir/dir/with_\\backslash" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_restore_untracked_color_colorizes_plain_untracked_lines() {
|
||||
local output
|
||||
|
||||
output=$(printf '?? plain.txt\n' | _forgit_restore_untracked_color '<u>' '<r>')
|
||||
|
||||
assert_same '<u>??<r> plain.txt' "$output"
|
||||
}
|
||||
|
||||
function test_forgit_restore_untracked_color_leaves_colored_lines_unchanged() {
|
||||
local colored output
|
||||
|
||||
colored=$'\033[33m??\033[m plain.txt'
|
||||
output=$(printf '%s\n' "$colored" | _forgit_restore_untracked_color '<u>' '<r>')
|
||||
|
||||
assert_same "$colored" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_worktree_changes_preserves_special_characters_in_payload() {
|
||||
local output path rootdir
|
||||
|
||||
path=$'tab\t space \\ name.txt'
|
||||
touch "$path"
|
||||
rootdir=$(git rev-parse --show-toplevel)
|
||||
|
||||
output=$(_forgit_worktree_changes)
|
||||
|
||||
assert_contains "${path}${_ffsep}${rootdir}/${path}" "$output"
|
||||
}
|
||||
|
||||
function test_forgit_fzf_separator_does_not_use_literal_tabs() {
|
||||
local delimiter
|
||||
|
||||
delimiter=$_ffsep
|
||||
|
||||
assert_not_contains $'\t' "$delimiter"
|
||||
}
|
||||
|
||||
function test_forgit_worktree_changes_works_in_zsh() {
|
||||
local output
|
||||
|
||||
output=$(
|
||||
zsh -c '
|
||||
source "'"$FORGIT_REPO_ROOT"'/bin/git-forgit"
|
||||
cd "$(mktemp -d)" || exit 1
|
||||
git init --quiet
|
||||
touch "space name.txt" "back\\slash.txt" $'"'"'tab\tname.txt'"'"'
|
||||
_forgit_worktree_changes
|
||||
'
|
||||
)
|
||||
|
||||
assert_contains 'space name.txt' "$output"
|
||||
assert_contains 'back\slash.txt' "$output"
|
||||
assert_contains 'tab' "$output"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue