From 26889680906b3a47371a213ac329e23ab255cece Mon Sep 17 00:00:00 2001 From: Chris Apple Date: Thu, 9 Jul 2026 14:59:56 -0700 Subject: [PATCH] perf: speed up modified file lookup in large repositories Use a modified-file-only path for `git-forgit restore` (`grs`) and `git-forgit checkout_file` (`gcf`) so these interactive selectors do not scan large sets of untracked files. This keeps both commands responsive in repositories with many untracked files. --- bin/git-forgit | 85 +++++++++++++++++++++--------- tests/working-tree-changes.test.sh | 63 ++++++++++++++++++++++ 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/bin/git-forgit b/bin/git-forgit index e0374f6..9315a0c 100755 --- a/bin/git-forgit +++ b/bin/git-forgit @@ -222,6 +222,61 @@ _forgit_list_files() { git ls-files -z "$@" "$rootdir" | tr '\0' '\n' | uniq } +_forgit_rewrite_repo_paths_for_cwd() { + local rootdir mode separator cwd + rootdir=$1 + mode=${2:-paths} + separator=${3:-} + cwd=$(pwd -P) + + perl -MCwd=realpath -MFile::Spec -e ' + use strict; + use warnings; + + my ($rootdir, $cwd, $mode, $separator) = @ARGV; + my $normalized_rootdir = realpath($rootdir); + my $normalized_cwd = realpath($cwd); + + while (my $line = ) { + chomp $line; + + my ($status, $repo_path); + if ($mode eq q{status_entries}) { + next unless $line =~ /^(..[^[:space:]]*)( )(.*)$/; + ($status, $repo_path) = ($1, $3); + } else { + next if $line eq q{}; + $repo_path = $line; + } + + my $absolute_path = "$normalized_rootdir/$repo_path"; + my $display_path = File::Spec->abs2rel(realpath($absolute_path) // $absolute_path, $normalized_cwd); + $display_path = q{.} if $display_path eq q{}; + + if ($mode eq q{status_entries}) { + print "[$status] ${display_path}${separator}${normalized_rootdir}/${repo_path}\n"; + } else { + print "$display_path\n"; + } + } + ' "$rootdir" "$cwd" "$mode" "$separator" +} + +_forgit_list_modified_files() { + # on large repos with many untracked files, git ls-files + # is substantially slower than git diff + # Wherever possible (when we don't care about untracked files) + # use this command over _forgit_list_files + + # See note on _forgit_list_files() about -z and other options + local rootdir + rootdir=$(git rev-parse --show-toplevel) + git -C "$rootdir" diff --name-only -z | + tr '\0' '\n' | + uniq | + _forgit_rewrite_repo_paths_for_cwd "$rootdir" +} + _forgit_list_staged_files() { local up up="$(git rev-parse --show-cdup)" @@ -291,32 +346,12 @@ _forgit_restore_untracked_color() { # 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 + local 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 = ) { - 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_rewrite_repo_paths_for_cwd "$rootdir" status_entries "$_ffsep" } _forgit_is_submodule() { @@ -739,7 +774,7 @@ _forgit_restore() { if [[ $worktree == true || $staged != true ]]; then while IFS='' read -r file; do candidates+=("$file") - done < <(_forgit_list_files --modified) + done < <(_forgit_list_modified_files) fi [[ ${#candidates[@]} -eq 0 ]] && echo "Nothing to restore." && return 1 @@ -1093,7 +1128,7 @@ _forgit_checkout_file() { _forgit_git_checkout_file "$@" return $? } - [[ $(_forgit_list_files --modified | wc -l) -eq 0 ]] && echo 'Nothing to checkout.' && return 1 + [[ $(_forgit_list_modified_files | wc -l) -eq 0 ]] && echo 'Nothing to checkout.' && return 1 opts=" $FORGIT_FZF_DEFAULT_OPTS -m -0 @@ -1103,7 +1138,7 @@ _forgit_checkout_file() { files=() while IFS='' read -r file; do files+=("$file") - done < <(_forgit_list_files --modified | + done < <(_forgit_list_modified_files | FZF_DEFAULT_OPTS="$opts" fzf) [[ ${#files[@]} -gt 0 ]] && _forgit_git_checkout_file "$@" "${files[@]}" } diff --git a/tests/working-tree-changes.test.sh b/tests/working-tree-changes.test.sh index 6e77433..831d2ef 100644 --- a/tests/working-tree-changes.test.sh +++ b/tests/working-tree-changes.test.sh @@ -44,6 +44,28 @@ function test_forgit_worktree_changes_contains_untracked() { assert_contains "untracked_file.txt" "$output" } +function test_forgit_list_files_matches_modified_files_until_untracked_files_exist() { + local list_files_output modified_files_output + + list_files_output=$(_forgit_list_files --modified) + modified_files_output=$(_forgit_list_modified_files) + + assert_same "$modified_files_output" "$list_files_output" + assert_contains "modified file.txt" "$list_files_output" + + touch new_untracked_file.txt + + list_files_output=$(_forgit_list_files --exclude-standard --modified --others) + modified_files_output=$(_forgit_list_modified_files) + + assert_contains "modified file.txt" "$list_files_output" + assert_contains "new_untracked_file.txt" "$list_files_output" + assert_contains "modified file.txt" "$modified_files_output" + assert_not_contains "new_untracked_file.txt" "$modified_files_output" + + rm new_untracked_file.txt +} + function test_forgit_worktree_changes_excludes_staged() { local output @@ -114,6 +136,47 @@ function test_forgit_build_status_entries_uses_cwd_relative_display_paths_for_si assert_contains "../dir1/file.txt" "$output" } +function test_forgit_list_modified_files_uses_cwd_relative_paths_for_sibling_entries() { + local output + + mkdir -p dir1 dir2 + touch dir1/file.txt + git add . + git commit -m "add dirs" --quiet + echo modified >dir1/file.txt + cd dir2 || return 1 + + output=$(_forgit_list_modified_files) + + assert_same "../dir1/file.txt" "$output" +} + +function test_forgit_rewrite_repo_paths_for_cwd_rewrites_repo_relative_paths() { + local output rootdir + + mkdir -p dir1 dir2 + touch dir1/file.txt + rootdir=$(git rev-parse --show-toplevel) + cd dir2 || return 1 + + output=$(printf 'dir1/file.txt\n' | _forgit_rewrite_repo_paths_for_cwd "$rootdir") + + assert_same "../dir1/file.txt" "$output" +} + +function test_forgit_rewrite_repo_paths_for_cwd_formats_status_entries() { + local output rootdir + + mkdir -p dir1 dir2 + touch dir1/file.txt + rootdir=$(git rev-parse --show-toplevel) + cd dir2 || return 1 + + output=$(printf ' M dir1/file.txt\n' | _forgit_rewrite_repo_paths_for_cwd "$rootdir" status_entries "$_ffsep") + + assert_same "[ M] ../dir1/file.txt${_ffsep}${rootdir}/dir1/file.txt" "$output" +} + function test_forgit_build_status_entries_uses_cwd_relative_display_paths_from_logical_symlink_paths() { local output sandbox