diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f4c4322..779a33a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,6 +58,15 @@ Source order is deterministic and lives at `scripts/gitb.sh:105-138`. `common.sh `init.sh` runs git config / remote probes at source time. Tests that only need the validators / sanitizers can short-circuit those by exporting `GITBASHER_SKIP_INIT_QUERIES=1` before sourcing — that's what the `source_gitbasher_lite` helper in `tests/setup_suite.bash` does. The flag is internal to the test layer; production runs never set it. +### Long-running commands and progress + +Anything that can take more than a moment must show that it is alive. Use the helpers in `common.sh` instead of a bare `out=$(git ... 2>&1)`: + +- **`stream_or_capture_git out_var code_var shown_var git --progress ...`** — for git commands (fetch, push, checkout/switch, merge, stash, worktree add). It captures output and exit code for parsing (progress meters stripped), shows a spinner with elapsed seconds after ~1s, and streams git's native progress if the command outlives a ~10s quiet window. Pass `--progress` only to subcommands that accept it; it is removed automatically when there is no TTY. +- **`run_with_spinner "Label" cmd args...`** — for anything else (AI requests). stdout passes through, so it works inside `$(...)`. The command runs in the background with stdin detached, so never use it for commands that prompt or open an editor. + +Both stay silent without a TTY (tests/CI) and honour `GITBASHER_NO_SPINNER=1`. Network commands skip the spinner when git might prompt for credentials (HTTPS without a credential helper, SSH without an agent) so the prompt is not overwritten. + ### Top-level dispatch `scripts/base.sh` is the dispatcher: a `case "$1"` block (`base.sh:127-229`) maps each command name and its aliases to the corresponding `*_script` function. The convention: `commit_script`, `push_script`, `branch_script`, etc. — defined in their respective files. diff --git a/FAQ.md b/FAQ.md index 2a8c29a..8523ba5 100644 --- a/FAQ.md +++ b/FAQ.md @@ -145,6 +145,10 @@ Run `gitb sync`. It shows the divergence and rebases by default (use `gitb sync Bare `gitb log` is now an interactive browser: a numbered, paginated commit list where picking a commit shows it and offers actions (diff, stat, copy hash, revert, cherry-pick, fixup, restore a file). The old full dump is still there as `gitb log all` (alias `dump`). Piping still works — non-interactive runs print a plain `git log`-style listing and exit. You can also pass an argument directly: a number (`gitb log 20`), a file path, a ref or range, or words to search commit messages. Page size: `git config gitbasher.log-count 30`. +### What is the spinner on long commands, and can I turn it off? + +Fetches, pushes, pulls, branch switches, merges, stashes, worktree creation and AI requests show a spinner with elapsed seconds once they take longer than about a second. If a fetch, push, or checkout runs past ~10s, git's own progress (`Receiving objects: 45%`, `Updating files: 80%`) takes over. Quick commands print nothing extra. Set `GITBASHER_NO_SPINNER=1` to turn the spinner off; it is also off automatically when output is not a terminal, and for network commands where git may ask for a password. + ## Contributing and bugs ### How do I report a bug? diff --git a/scripts/ai.sh b/scripts/ai.sh index ed26d74..ea81b21 100644 --- a/scripts/ai.sh +++ b/scripts/ai.sh @@ -1129,7 +1129,7 @@ function call_ai_api { # demands bare JSON and squash_parse_ai_plan extracts and validates it. if [ "$provider" = "claude" ]; then local claude_out - claude_out=$(_call_claude_cli "$system_prompt" "$user_prompt" "$model") + claude_out=$(run_with_spinner "Waiting for AI response" _call_claude_cli "$system_prompt" "$user_prompt" "$model") if [ $? -ne 0 ] || [ -z "$claude_out" ]; then clear_sensitive_vars return 1 @@ -1272,9 +1272,9 @@ function call_ai_api { # Retry logic for server errors while [ $retry_count -le $max_retries ]; do if [ -n "$safe_proxy_url" ]; then - response=$(secure_curl_with_api_key "$safe_proxy_url" "$api_key" "$json_payload" "$api_url" "$provider") + response=$(run_with_spinner "Waiting for AI response" secure_curl_with_api_key "$safe_proxy_url" "$api_key" "$json_payload" "$api_url" "$provider") else - response=$(secure_curl_with_api_key "" "$api_key" "$json_payload" "$api_url" "$provider") + response=$(run_with_spinner "Waiting for AI response" secure_curl_with_api_key "" "$api_key" "$json_payload" "$api_url" "$provider") fi curl_exit_code=$? @@ -1519,6 +1519,14 @@ function call_ai_api { } +### call_ai_api with its stderr (error messages, inner spinner) silenced, +### for callers that report failures themselves; wrap it in run_with_spinner +### to keep the wait visible. +function call_ai_api_quiet { + call_ai_api "$@" 2>/dev/null +} + + ### Function to check if AI tools are available # Returns: 0 if available, 1 if not function check_ai_available { diff --git a/scripts/branch.sh b/scripts/branch.sh index 61dd5c4..1193896 100644 --- a/scripts/branch.sh +++ b/scripts/branch.sh @@ -170,8 +170,9 @@ function branch_script { echo -e "${YELLOW}Fetching and pruning remote references...${ENDCOLOR}" echo - fetch_output=$(git fetch --prune 2>&1) - check_code $? "$fetch_output" "fetch and prune remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress --prune + check_code "$fetch_code" "$fetch_output" "fetch and prune remote" # Find local branches whose upstream is gone. Plumbing, not # `git branch -vv` scraping: the human format prefixes worktree @@ -229,8 +230,9 @@ function branch_script { if [ "$choice" = "y" ] || [ "$choice" = "Y" ]; then echo echo -e "${YELLOW}Fetching remote tags...${ENDCOLOR}" - fetch_output=$(git fetch --tags 2>&1) - check_code $? "$fetch_output" "fetch remote tags" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress --tags + check_code "$fetch_code" "$fetch_output" "fetch remote tags" fi echo @@ -285,8 +287,8 @@ function branch_script { echo -e "${RED}✗ Invalid tag name: ${selected_tag}${ENDCOLOR}" exit 1 fi - checkout_output=$(git checkout "$sanitized_git_name" 2>&1) - checkout_code=$? + stream_or_capture_git checkout_output checkout_code checkout_progress_shown \ + git checkout --progress "$sanitized_git_name" if [ $checkout_code -eq 0 ]; then echo -e "${GREEN}✓ Checked out tag '${selected_tag}'${ENDCOLOR}" @@ -319,8 +321,9 @@ function branch_script { echo -e "${YELLOW}Fetching remote...${ENDCOLOR}" echo - fetch_output=$(git fetch 2>&1) - check_code $? "$fetch_output" "fetch remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress + check_code "$fetch_code" "$fetch_output" "fetch remote" prune_output=$(git remote prune "$origin_name" 2>&1) @@ -350,8 +353,9 @@ function branch_script { echo -e "${YELLOW}Fetching remote and pruning...${ENDCOLOR}" echo - fetch_output=$(git fetch --prune 2>&1) - check_code $? "$fetch_output" "fetch and prune remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress --prune + check_code "$fetch_code" "$fetch_output" "fetch and prune remote" # Get all local branches IFS=$'\n' read -rd '' -a local_branches <<<"$(git branch --format='%(refname:short)' | cat 2>&1)" @@ -597,8 +601,8 @@ function branch_script { echo echo -e "${YELLOW}Deleting from remote...${ENDCOLOR}" - push_output=$(LC_ALL=C git push "$origin_name" -d "$branch_name" 2>&1) - push_code=$? + LC_ALL=C stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress "$origin_name" -d "$branch_name" echo if [ "$push_code" != 0 ]; then diff --git a/scripts/commit.sh b/scripts/commit.sh index ed8a1a8..04f57c1 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -735,7 +735,9 @@ Read to decide which files implement the same feature. Output TSV (scope< local ai_response # "low" reasoning: grouping is a structural decision over many files — # worth ~1s of thinking, unlike plain message generation (see call_ai_api). - ai_response=$(call_ai_api "$system_prompt" "$user_prompt" "$AI_MAX_TOKENS_FULL" "" "" "low" 2>/dev/null) + # The spinner wraps a stderr-silenced call: errors stay hidden (the caller + # reports its own fallback reason) while the wait is still visible. + ai_response=$(run_with_spinner "Grouping changes with AI" call_ai_api_quiet "$system_prompt" "$user_prompt" "$AI_MAX_TOKENS_FULL" "" "" "low") if [ $? -ne 0 ] || [ -z "$ai_response" ]; then ai_grouping_fail_reason="API error" return 1 diff --git a/scripts/common.sh b/scripts/common.sh index a5e7f70..a81f99a 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -858,23 +858,107 @@ function is_network_error { } +### ===== PROGRESS: spinner + live git progress for long operations ===== +### Every slow action (network transfers, big checkouts, AI requests) shows +### that it is alive: a spinner with elapsed seconds appears on stderr after +### ~1s, so quick operations print nothing extra. Disabled when stderr is not +### a terminal (tests/CI/pipes), for TERM=dumb, or with GITBASHER_NO_SPINNER=1. +function _spinner_enabled { + [ -t 2 ] && [ -z "${GITBASHER_NO_SPINNER:-}" ] && [ "${TERM:-}" != "dumb" ] +} + +### Draw one spinner frame on stderr, overwriting the current line +# $1: frame counter, $2: label, $3: elapsed seconds +function _spinner_draw { + local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") + local frame=${frames[$(( $1 % ${#frames[@]} ))]} + printf '\r\033[K%b%s%b %s %b(%ss)%b' "${YELLOW}" "$frame" "${ENDCOLOR}" "$2" "${GRAY}" "$3" "${ENDCOLOR}" >&2 +} + +### Erase the spinner line +function _spinner_clear { + printf '\r\033[K' >&2 +} + + +### Run a command (or shell function) while showing a spinner on stderr. +### stdout is passed through unchanged once the command finishes, so it is +### safe inside $(...): `out=$(run_with_spinner "Waiting for AI" call ...)`. +### The command runs in the background with stdin detached — do not use it +### for anything that reads from the user or opens an editor. +# $1: label shown next to the spinner +# $2..: the command and its args +# Returns: the command's exit code +function run_with_spinner { + local __rs_label="$1"; shift + if ! _spinner_enabled; then + "$@" + return + fi + local __rs_tmp + __rs_tmp=$(mktemp 2>/dev/null) + if [ -z "$__rs_tmp" ]; then + "$@" + return + fi + "$@" >"$__rs_tmp" & + local __rs_pid=$! __rs_ticks=0 __rs_start=$SECONDS __rs_shown="" __rs_code=0 + while kill -0 "$__rs_pid" 2>/dev/null; do + sleep 0.1 + __rs_ticks=$((__rs_ticks + 1)) + if [ "$__rs_ticks" -ge 8 ]; then + _spinner_draw "$__rs_ticks" "$__rs_label" $((SECONDS - __rs_start)) + __rs_shown="true" + fi + done + # `if` keeps errexit from firing on a failing command. + if wait "$__rs_pid"; then __rs_code=0; else __rs_code=$?; fi + if [ -n "$__rs_shown" ]; then _spinner_clear; fi + cat "$__rs_tmp" + rm -f "$__rs_tmp" + return "$__rs_code" +} + + +### Remove git's progress meter lines (\r-updated counters such as +### "Receiving objects: 45%") from captured output, leaving what a run +### without --progress would print — messages, refs, errors. +# $1: captured output +function strip_git_progress { + local cr=$'\r' + printf '%s\n' "$1" \ + | sed -e "s/.*${cr}\(.\)/\1/" -e "s/${cr}\$//" \ + | grep -vE '^(remote: )?(Enumerating objects|Counting objects|Compressing objects|Writing objects|Receiving objects|Resolving deltas|Unpacking objects|Updating files|Checking out files|Finding sources): ' \ + || true +} + + ### Wait on a background transfer, revealing its captured output live only ### once it outlives the quiet window. Fast transfers finish inside the window ### and print nothing; slow ones stream everything captured so far and then -### follow the file until the command exits. Returns the command's exit code. +### follow the file until the command exits. While the window is still quiet +### a spinner (when $5 is given) shows the transfer is alive. +### Returns the command's exit code. # $1: pid of the background command # $2: file capturing the command's combined stdout+stderr # $3: quiet window length in ticks of 0.25s (40 = ~10s) # $4: name of the variable to receive "true" when output was revealed +# $5: optional spinner label shown during the quiet window function _stream_transfer_after_delay { - local __st_pid="$1" __st_file="$2" __st_quiet="${3:-40}" __st_flag_var="$4" + local __st_pid="$1" __st_file="$2" __st_quiet="${3:-40}" __st_flag_var="$4" __st_label="${5:-}" local __st_ticks=0 __st_printed=0 __st_size=0 __st_code=0 __st_revealed="" + local __st_spin="" __st_drawn="" __st_start=$SECONDS + if [ -n "$__st_label" ] && _spinner_enabled; then __st_spin="true"; fi while kill -0 "$__st_pid" 2>/dev/null; do sleep 0.25 __st_ticks=$((__st_ticks + 1)) if [ "$__st_ticks" -ge "$__st_quiet" ]; then __st_size=$(wc -c < "$__st_file" 2>/dev/null); __st_size=$((__st_size + 0)) if [ "$__st_size" -gt "$__st_printed" ]; then + if [ -n "$__st_drawn" ]; then + _spinner_clear + __st_drawn="" + fi # Raw byte pass-through keeps git's \r-updating progress lines # rendering exactly as they would uncaptured. dd if="$__st_file" bs=1 skip="$__st_printed" count=$((__st_size - __st_printed)) 2>/dev/null @@ -882,9 +966,18 @@ function _stream_transfer_after_delay { __st_revealed="true" fi fi + # Keep spinning until git's own progress takes over (some commands + # print nothing for a long time, e.g. a server-side pack on fetch). + if [ -z "$__st_revealed" ] && [ -n "$__st_spin" ] && [ "$__st_ticks" -ge 4 ]; then + _spinner_draw "$__st_ticks" "$__st_label" $((SECONDS - __st_start)) + __st_drawn="true" + fi done # Reaps the background pid; `if` keeps errexit from firing on failures. if wait "$__st_pid"; then __st_code=0; else __st_code=$?; fi + if [ -n "$__st_drawn" ]; then + _spinner_clear + fi if [ -n "$__st_revealed" ]; then __st_size=$(wc -c < "$__st_file" 2>/dev/null); __st_size=$((__st_size + 0)) if [ "$__st_size" -gt "$__st_printed" ]; then @@ -898,14 +991,73 @@ function _stream_transfer_after_delay { } -### Run a git (or any) transfer command, capturing combined stdout+stderr and -### the exit code for downstream parsing. Interactive runs start silent and -### reveal the captured stream live only when the transfer outlives a ~10s -### quiet window — an ordinary network push (2-5s of ssh handshake and pack -### upload) stays noise-free; only genuinely large transfers show git's -### native progress. Falls back to silent capture when there is no TTY -### (tests/CI) or mktemp fails; in that fallback --progress is stripped -### from the args so it can't pollute the captured output. +### Human label for the spinner of a git command ("git fetch ..." -> "Fetching") +# $@: the command and its args +function _progress_label { + if [ "$1" != "git" ]; then + printf '%s' "$1" + return + fi + case "$2" in + fetch) printf 'Fetching';; + push) printf 'Pushing';; + pull) printf 'Pulling';; + merge) printf 'Merging';; + switch|checkout) printf 'Checking out files';; + stash) + case "$3" in + pop|apply) printf 'Applying stash';; + drop) printf 'Dropping stash';; + *) printf 'Stashing';; + esac;; + worktree) printf 'Creating worktree';; + remote) printf 'Updating remotes';; + clone) printf 'Cloning';; + *) printf 'Running git %s' "$2";; + esac +} + + +### Whether a network git command may ask for credentials on the terminal +### (HTTPS without a credential helper/askpass, SSH without an agent). The +### spinner redraws its line every tick and would wipe such a prompt, so it +### stays off then — git's own progress still appears after the quiet window. +# $@: the command and its args +# Returns: 0 (true) when a credential prompt is plausible +function _git_may_prompt_for_credentials { + [ "$1" = "git" ] || return 1 + case "$2" in + fetch|push|pull|remote|clone) ;; + *) return 1;; + esac + [ "${GIT_TERMINAL_PROMPT:-}" = "0" ] && return 1 + local url + url=$(git remote get-url "${origin_name:-origin}" 2>/dev/null) + case "$url" in + http://*|https://*) + if [ -z "${GIT_ASKPASS:-}" ] && [ -z "$(git config --get credential.helper 2>/dev/null)" ] \ + && [ -z "$(git config --get core.askPass 2>/dev/null)" ]; then + return 0 + fi;; + ssh://*|*@*:*) + if [ -z "${SSH_AUTH_SOCK:-}" ] && [ -z "${SSH_ASKPASS:-}" ]; then + return 0 + fi;; + esac + return 1 +} + + +### Run a git (or any) long-running command, capturing combined +### stdout+stderr and the exit code for downstream parsing. Interactive runs +### show a spinner after ~1s and reveal the captured stream live only when +### the command outlives a ~10s quiet window — an ordinary network push +### (2-5s of ssh handshake and pack upload) stays noise-free; only genuinely +### large transfers/checkouts show git's native progress. Progress meter +### lines are stripped from the captured output so callers parse the same +### text as without --progress. Falls back to silent capture when there is +### no TTY (tests/CI) or mktemp fails; in that fallback --progress is +### stripped from the args so it can't pollute the captured output. # $1: name of the variable to receive captured output # $2: name of the variable to receive the exit code # $3: name of the variable to receive "true" when output was streamed live @@ -923,9 +1075,13 @@ function stream_or_capture_git { if [ -n "$__tmp" ]; then __ran="true" "$@" >"$__tmp" 2>&1 & - _stream_transfer_after_delay $! "$__tmp" "${GITBASHER_PROGRESS_QUIET_TICKS:-40}" __shown + local __label="" + if ! _git_may_prompt_for_credentials "$@"; then + __label=$(_progress_label "$@") + fi + _stream_transfer_after_delay $! "$__tmp" "${GITBASHER_PROGRESS_QUIET_TICKS:-40}" __shown "$__label" __code=$? - __output=$(cat "$__tmp") + __output=$(strip_git_progress "$(cat "$__tmp")") rm -f "$__tmp" fi fi @@ -1758,25 +1914,80 @@ function choose_commit { ### Function prints provided stat in a nice format with colors # $1: stats after pull or commit like 'README.md | 1 +\n1 file changed, 1 insertion(+)' +# $2: optional max number of file rows to print (0 or empty = all); the rest +# collapse into an "... and N more files" line so huge pulls stay readable +# Single awk pass: per-line subshells made a 2000-file pull hang for ~20s. function print_changes_stat { - IFS=$'\n' read -rd '' -a stats <<< "$1" || true - result_stat="" - bottom_line="" - number_of_stats=${#stats[@]} - for index in "${!stats[@]}" - do - s=$(echo ${stats[index]} | sed -e 's/^[[:space:]]*//') - s=$(sed "s/+/${GREEN_ES}+${ENDCOLOR_ES}/g" <<< ${s}) - s=$(sed "s/-/${RED_ES}-${ENDCOLOR_ES}/g" <<< ${s}) - if [ $(($index+1)) == $number_of_stats ]; then - #s=$(sed '1 s/,/|/' <<< ${s}) - bottom_line="${s}" - break + local input limit="${2:-0}" files bottom total more=0 + input=$(printf '%s\n' "$1" | sed '/^[[:space:]]*$/d') + if [ -z "$input" ]; then + return + fi + ### Summary is the "N files changed" line; git merge may append + ### "create mode ..." rows after it, which stay below the summary. + local summary_at extra="" + summary_at=$(printf '%s\n' "$input" | grep -nE '[0-9]+ files? changed' | tail -n 1 | cut -d: -f1) + if [ -z "$summary_at" ]; then + summary_at=$(printf '%s\n' "$input" | wc -l | tr -d ' ') + fi + bottom=$(printf '%s\n' "$input" | sed -n "${summary_at}p") + files="" + if [ "$summary_at" -gt 1 ]; then + files=$(printf '%s\n' "$input" | head -n $((summary_at - 1))) + fi + extra=$(printf '%s\n' "$input" | tail -n +$((summary_at + 1))) + if [ -n "$files" ] && [ "$limit" -gt 0 ] 2>/dev/null; then + total=$(printf '%s\n' "$files" | wc -l | tr -d ' ') + if [ "$total" -gt "$limit" ]; then + more=$((total - limit)) + files=$(printf '%s\n' "$files" | head -n "$limit") fi - result_stat="${result_stat}\n${s}" - done - echo -e "$(echo -e "${result_stat}" | column -ts'|')" - echo -e "$bottom_line" + fi + + if [ -n "$files" ]; then + local aligned + aligned=$(printf '%s\n' "$files" | _colorize_stat_lines "file") + if command -v column >/dev/null 2>&1; then + aligned=$(printf '%s\n' "$aligned" | column -ts'|') + fi + echo -e "$aligned" + fi + if [ "$more" -gt 0 ]; then + echo -e "${GRAY}... and ${more} more files${ENDCOLOR}" + fi + echo -e "$(printf '%s\n' "$bottom" | _colorize_stat_lines "summary")" + if [ -n "$extra" ] && [ "$more" -eq 0 ]; then + printf '%s\n' "$extra" + fi +} + + +### Colorize +/- runs of git --stat lines read from stdin (helper for print_changes_stat) +# $1: "file" - trim, collapse spaces and color only the graph after the last '|' +# (so hyphens in file names stay plain); "summary" - color the whole line +# Colors come in via ENVIRON so awk never reinterprets the \x1b escapes; +# the caller's echo -e turns them into real escape codes. +function _colorize_stat_lines { + __G="$GREEN_ES" __R="$RED_ES" __E="$ENDCOLOR_ES" awk -v mode="$1" ' + function paint(str, out, ch) { + out = "" + while (match(str, /[+]+|-+/)) { + ch = substr(str, RSTART, 1) + out = out substr(str, 1, RSTART - 1) (ch == "+" ? ENVIRON["__G"] : ENVIRON["__R"]) substr(str, RSTART, RLENGTH) ENVIRON["__E"] + str = substr(str, RSTART + RLENGTH) + } + return out str + } + { + s = $0 + gsub(/^[ \t]+|[ \t]+$/, "", s) + gsub(/[ \t]+/, " ", s) + if (mode == "file" && match(s, /[|][^|]*$/)) { + print substr(s, 1, RSTART) paint(substr(s, RSTART + 1)) + } else { + print paint(s) + } + }' } @@ -2218,8 +2429,8 @@ function choose_branch { # $1: name of the branch to switch # $2: pass it if you want to disable push log and moved changes function switch { - switch_output=$(git switch "$1" 2>&1) - switch_code=$? + stream_or_capture_git switch_output switch_code switch_progress_shown \ + git switch --progress "$1" ## Switch is OK if [ "$switch_code" == 0 ]; then diff --git a/scripts/config.sh b/scripts/config.sh index f7e0e98..0290630 100644 --- a/scripts/config.sh +++ b/scripts/config.sh @@ -16,8 +16,9 @@ function set_default_branch { echo -e "${YELLOW}Fetching remote branches...${ENDCOLOR}" echo - fetch_output=$(git fetch 2>&1) - check_code $? "$fetch_output" "fetch remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress + check_code "$fetch_code" "$fetch_output" "fetch remote" prune_output=$(git remote prune $origin_name 2>&1) diff --git a/scripts/edit.sh b/scripts/edit.sh index cd9c709..55190fc 100644 --- a/scripts/edit.sh +++ b/scripts/edit.sh @@ -236,9 +236,10 @@ function edit_branch_name { yes_no_choice_strict echo - local push_out - push_out=$(git push -u "$origin_name" "$new_name" 2>&1) - if [ $? -ne 0 ]; then + local push_out push_code push_progress_shown + stream_or_capture_git push_out push_code push_progress_shown \ + git push --progress -u "$origin_name" "$new_name" + if [ "$push_code" -ne 0 ]; then echo -e "${RED}✗ Could not push ${BOLD}${new_name}${NORMAL}${RED} to ${origin_name}.${ENDCOLOR}" echo "$push_out" echo -e "Old remote branch ${BOLD}${old_name}${NORMAL} left intact." @@ -246,9 +247,10 @@ function edit_branch_name { fi echo -e "${GREEN}✓ Pushed ${BOLD}${new_name}${NORMAL}${GREEN} to ${origin_name}${ENDCOLOR}" - local del_out - del_out=$(git push "$origin_name" --delete "$old_name" 2>&1) - if [ $? -ne 0 ]; then + local del_out del_code del_progress_shown + stream_or_capture_git del_out del_code del_progress_shown \ + git push --progress "$origin_name" --delete "$old_name" + if [ "$del_code" -ne 0 ]; then echo -e "${YELLOW}⚠ Could not delete ${BOLD}${old_name}${NORMAL}${YELLOW} on ${origin_name}.${ENDCOLOR}" echo "$del_out" echo -e "Delete it manually with ${YELLOW}git push ${origin_name} --delete ${old_name}${ENDCOLOR} when ready." diff --git a/scripts/fetch.sh b/scripts/fetch.sh index 51a7bd5..561f725 100644 --- a/scripts/fetch.sh +++ b/scripts/fetch.sh @@ -94,8 +94,9 @@ function fetch_script { echo -e "${YELLOW}Fetching and pruning '$origin_name'...${ENDCOLOR}" echo - fetch_output=$(git fetch --prune "$origin_name" 2>&1) - check_code $? "$fetch_output" "fetch and prune remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress --prune "$origin_name" + check_code "$fetch_code" "$fetch_output" "fetch and prune remote" pruned=$(pruned_branch_names "$fetch_output") if [ -n "$pruned" ]; then diff --git a/scripts/merge.sh b/scripts/merge.sh index 64f1ddf..ed2e424 100644 --- a/scripts/merge.sh +++ b/scripts/merge.sh @@ -125,8 +125,9 @@ function merge_script { echo -e "${YELLOW}Fetching remote...${ENDCOLOR}" echo - fetch_output=$(git fetch 2>&1) - check_code $? "$fetch_output" "fetch remote" + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress + check_code "$fetch_code" "$fetch_output" "fetch remote" prune_output=$(git remote prune "$origin_name" 2>&1) @@ -229,7 +230,7 @@ function merge_script { if [ -n "$changes" ]; then echo - print_changes_stat "$changes" + print_changes_stat "$changes" 100 fi @@ -257,11 +258,12 @@ function merge { args="--ff-only" fi if [ "$5" == "true" ]; then - merge_output=$(LC_ALL=C git merge $args "$2/$1" 2>&1) + LC_ALL=C stream_or_capture_git merge_output merge_code merge_progress_shown \ + git merge --progress $args "$2/$1" else - merge_output=$(LC_ALL=C git merge $args "$1" 2>&1) + LC_ALL=C stream_or_capture_git merge_output merge_code merge_progress_shown \ + git merge --progress $args "$1" fi - merge_code=$? if [ $merge_code == 0 ] ; then return diff --git a/scripts/pull.sh b/scripts/pull.sh index 559da33..b9fd480 100644 --- a/scripts/pull.sh +++ b/scripts/pull.sh @@ -106,8 +106,8 @@ function pull_script { remote_tracking_ref="refs/remotes/$origin_name/$current_branch" saved_ref=$(git rev-parse --verify --quiet "$remote_tracking_ref") - dry_output=$(git fetch --no-tags "$origin_name" "$current_branch" 2>&1) - dry_code=$? + stream_or_capture_git dry_output dry_code dry_progress_shown \ + git fetch --progress --no-tags "$origin_name" "$current_branch" ### Restore the remote-tracking ref to its pre-dry-run value if [ -n "$saved_ref" ]; then @@ -191,8 +191,8 @@ function pull_script { if [ -n "$update" ]; then echo -e "${YELLOW}Updating from remote...${ENDCOLOR}" echo - update_output=$(git remote update 2>&1) - update_code=$? + stream_or_capture_git update_output update_code update_progress_shown \ + git remote update if [ $update_code == 0 ] ; then if git rev-parse --verify --quiet "refs/remotes/$origin_name/$current_branch" >/dev/null 2>&1; then commits=$(commit_list 999 "tab" "HEAD..$origin_name/$current_branch"); else commits=""; fi @@ -271,8 +271,14 @@ function pull { return fi - merge_output=$(git merge --ff-only $2/$1 2>&1) - merge_code=$? + ### A huge fast-forward spends a long time checking out files — let git's + ### "Updating files" progress show once it outlives the quiet window. + ### --no-stat keeps a streamed run from dumping the whole diffstat live; + ### the stat is printed below from old_head..HEAD instead. + local old_head + old_head=$(git rev-parse HEAD 2>/dev/null) + stream_or_capture_git merge_output merge_code merge_progress_shown \ + git merge --ff-only --no-stat --progress "$2/$1" mode="fast-forward" if [[ $merge_output == *"Already up to date"* ]]; then @@ -342,7 +348,13 @@ function pull { echo -e "${GREEN}✓ Pulled using $mode${ENDCOLOR}" - if [ "$mode" == "merge" ] || [ "$mode" == "fast-forward" ]; then + if [ "$mode" == "fast-forward" ]; then + changes=$(git --no-pager diff --stat "$old_head" HEAD 2>/dev/null) + if [[ -n "$changes" ]]; then + echo + print_changes_stat "$changes" 100 + fi + elif [ "$mode" == "merge" ]; then ### Merge without conflicts if [ $merge_code == 0 ] ; then if [[ $merge_output == *"made by the"* ]]; then @@ -352,7 +364,7 @@ function pull { fi if [[ -n "$changes" ]]; then echo - print_changes_stat "$changes" + print_changes_stat "$changes" 100 fi ### Merge with conflicts, but they were resolved @@ -360,7 +372,7 @@ function pull { commit_hash="$(git --no-pager log --pretty="%h" -1)" changes=$(git --no-pager show $commit_hash --stat --format="") if [[ -n "$changes" ]]; then - print_changes_stat "$changes" + print_changes_stat "$changes" 100 fi fi fi diff --git a/scripts/squash.sh b/scripts/squash.sh index 9ad7a57..ee04cbb 100644 --- a/scripts/squash.sh +++ b/scripts/squash.sh @@ -893,9 +893,9 @@ function squash_script { ### Optional force-push if [ -n "$mode_push" ]; then echo -e "${YELLOW}Force-pushing with --force-with-lease...${ENDCOLOR}" - local push_output push_code - push_output=$(git push --force-with-lease -u "$origin_name" "$current_branch" 2>&1) - push_code=$? + local push_output push_code push_progress_shown + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress --force-with-lease -u "$origin_name" "$current_branch" if [ "$push_code" -eq 0 ]; then echo -e "${GREEN}✓ Pushed${ENDCOLOR}" echo "$push_output" | tail -n 5 diff --git a/scripts/stash.sh b/scripts/stash.sh index 6d7c5c6..8bfcdcb 100644 --- a/scripts/stash.sh +++ b/scripts/stash.sh @@ -263,8 +263,8 @@ function stash_script { [ -n "$_sp" ] && _stash_paths+=("$_sp") done < <( set -f; for _sp in $git_add; do printf '%s\n' "$_sp"; done ) - stash_output=$(git stash push -m "$stash_message" -u -- "${_stash_paths[@]}" 2>&1) - stash_code=$? + stream_or_capture_git stash_output stash_code stash_progress_shown \ + git stash push -m "$stash_message" -u -- "${_stash_paths[@]}" if [ $stash_code -eq 0 ]; then echo -e "${GREEN}✓ Stashed selected files${ENDCOLOR}" @@ -302,8 +302,8 @@ function stash_script { echo -e "${YELLOW}Stashing all changes...${ENDCOLOR}" echo - stash_output=$(git stash push -m "$stash_message" --include-untracked 2>&1) - stash_code=$? + stream_or_capture_git stash_output stash_code stash_progress_shown \ + git stash push -m "$stash_message" --include-untracked if [ $stash_code -eq 0 ]; then echo -e "${GREEN}✓ Stashed all changes${ENDCOLOR}" @@ -331,8 +331,8 @@ function stash_script { echo -e "${YELLOW}Popping from stash $selected_stash...${ENDCOLOR}" echo - pop_output=$(git stash pop "$selected_stash" 2>&1) - pop_code=$? + stream_or_capture_git pop_output pop_code pop_progress_shown \ + git stash pop "$selected_stash" if [ $pop_code -eq 0 ]; then echo -e "${GREEN}✓ Popped stash${ENDCOLOR}" @@ -362,8 +362,8 @@ function stash_script { echo -e "${YELLOW}Applying stash $selected_stash...${ENDCOLOR}" echo - apply_output=$(git stash apply "$selected_stash" 2>&1) - apply_code=$? + stream_or_capture_git apply_output apply_code apply_progress_shown \ + git stash apply "$selected_stash" if [ $apply_code -eq 0 ]; then echo -e "${GREEN}✓ Applied stash${ENDCOLOR}" @@ -386,8 +386,8 @@ function stash_script { echo -e "Are you sure you want to drop it (y/N)?" yes_no_choice_strict "Dropping stash" - drop_output=$(git stash drop "$selected_stash" 2>&1) - drop_code=$? + stream_or_capture_git drop_output drop_code drop_progress_shown \ + git stash drop "$selected_stash" if [ $drop_code -eq 0 ]; then echo -e "${GREEN}✓ Dropped stash${ENDCOLOR}" diff --git a/scripts/sync.sh b/scripts/sync.sh index 26979cc..7e45e35 100644 --- a/scripts/sync.sh +++ b/scripts/sync.sh @@ -94,8 +94,8 @@ function sync_script { remote_tracking_ref="refs/remotes/$origin_name/$main_branch" saved_ref=$(git rev-parse --verify --quiet "$remote_tracking_ref") - dry_output=$(git fetch --no-tags "$origin_name" "$main_branch" 2>&1) - dry_code=$? + stream_or_capture_git dry_output dry_code dry_progress_shown \ + git fetch --progress --no-tags "$origin_name" "$main_branch" if [ -n "$saved_ref" ]; then git update-ref "$remote_tracking_ref" "$saved_ref" 2>/dev/null diff --git a/scripts/tag.sh b/scripts/tag.sh index adb0478..ab097be 100644 --- a/scripts/tag.sh +++ b/scripts/tag.sh @@ -14,13 +14,15 @@ function push_tag { if [ -z "$1" ] || [ "$1" == "" ]; then all="true" - push_output=$(git push "$origin_name" --tags 2>&1) + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress "$origin_name" --tags elif [ -n "$2" ]; then - push_output=$(git push --delete "$origin_name" "$1" 2>&1) + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress --delete "$origin_name" "$1" else - push_output=$(git push "$origin_name" "$1" 2>&1) + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress "$origin_name" "$1" fi - push_code=$? # Handle delete case if [ -n "$delete" ]; then @@ -224,8 +226,8 @@ function tag_script { ### Fetch tags from the remote if [ -n "${remote}" ]; then echo -e "${YELLOW}Fetching tags from the remote...${ENDCOLOR}" - fetch_output=$(git fetch $origin_name --tags 2>&1) - fetch_code=$? + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress $origin_name --tags # A non-zero exit caused only by tag-clobber protection is a warning, # not a fatal error — still show the tag list. Anything else aborts. if [ "$fetch_code" != 0 ] && ! is_tag_clobber_only "$fetch_output"; then diff --git a/scripts/wip.sh b/scripts/wip.sh index e7200d1..4148b1c 100644 --- a/scripts/wip.sh +++ b/scripts/wip.sh @@ -173,12 +173,12 @@ function wip_push_stash_backup { local remote_branch remote_branch="$(wip_remote_branch)" echo -e "${YELLOW}Pushing WIP backup to ${origin_name}/${remote_branch}...${ENDCOLOR}" - local push_output push_code + local push_output push_code push_progress_shown # --force-with-lease refuses to clobber a backup branch someone else may # have pushed; falls back to creating it on first run. Strictly safer than # bare --force without changing the wip flow's intent. - push_output=$(git push --force-with-lease "${origin_name}" "stash@{0}:refs/heads/${remote_branch}" 2>&1) - push_code=$? + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress --force-with-lease "${origin_name}" "stash@{0}:refs/heads/${remote_branch}" if [ $push_code -eq 0 ]; then echo -e "${GREEN}✓ Pushed WIP backup to ${origin_name}/${remote_branch}${ENDCOLOR}" else @@ -196,11 +196,11 @@ function wip_push_branch { return fi echo -e "${YELLOW}Pushing ${branch} to ${origin_name}...${ENDCOLOR}" - local push_output push_code + local push_output push_code push_progress_shown # See note in wip_push_stash_backup — prefer --force-with-lease so we # don't silently overwrite a wip branch updated from another machine. - push_output=$(git push --force-with-lease --set-upstream "${origin_name}" "${branch}" 2>&1) - push_code=$? + stream_or_capture_git push_output push_code push_progress_shown \ + git push --progress --force-with-lease --set-upstream "${origin_name}" "${branch}" if [ $push_code -eq 0 ]; then echo -e "${GREEN}✓ Pushed to ${origin_name}/${branch}${ENDCOLOR}" else @@ -220,9 +220,9 @@ function wip_delete_remote_branch { return fi echo -e "${YELLOW}Removing remote WIP backup ${origin_name}/${branch}...${ENDCOLOR}" - local out code - out=$(git push "$origin_name" --delete "$branch" 2>&1) - code=$? + local out code out_progress_shown + stream_or_capture_git out code out_progress_shown \ + git push --progress "$origin_name" --delete "$branch" if [ $code -eq 0 ]; then echo -e "${GREEN}✓ Removed remote WIP backup${ENDCOLOR}" else @@ -245,8 +245,8 @@ function wip_up_stash { local message message="$(wip_stash_message)" local stash_output stash_code - stash_output=$(git stash push --include-untracked --message "$message" 2>&1) - stash_code=$? + stream_or_capture_git stash_output stash_code stash_progress_shown \ + git stash push --include-untracked --message "$message" if [ $stash_code -ne 0 ]; then echo -e "${RED}✗ Cannot stash changes.${ENDCOLOR}" @@ -288,9 +288,10 @@ function wip_up_branch { wip_branch="$(wip_remote_branch)" # Stash everything (including untracked) so we can apply onto the wip branch - local stash_output - stash_output=$(git stash push --include-untracked --message "wip-transfer: ${original_branch}" 2>&1) - if [ $? -ne 0 ]; then + local stash_output stash_code + stream_or_capture_git stash_output stash_code stash_progress_shown \ + git stash push --include-untracked --message "wip-transfer: ${original_branch}" + if [ "$stash_code" -ne 0 ]; then echo -e "${RED}✗ Cannot stash changes for transfer.${ENDCOLOR}" echo "$stash_output" exit 1 @@ -308,9 +309,10 @@ function wip_up_branch { fi # Apply the stash on top of the new branch - local pop_output - pop_output=$(git stash pop 2>&1) - if [ $? -ne 0 ]; then + local pop_output pop_code + stream_or_capture_git pop_output pop_code pop_progress_shown \ + git stash pop + if [ "$pop_code" -ne 0 ]; then echo -e "${RED}✗ Cannot apply WIP onto ${wip_branch}.${ENDCOLOR}" echo "$pop_output" echo @@ -383,18 +385,20 @@ function wip_up_worktree { echo # Stash so the worktree-add starts from a clean state - local stash_output - stash_output=$(git stash push --include-untracked --message "wip-transfer: ${current_branch}" 2>&1) - if [ $? -ne 0 ]; then + local stash_output stash_code + stream_or_capture_git stash_output stash_code stash_progress_shown \ + git stash push --include-untracked --message "wip-transfer: ${current_branch}" + if [ "$stash_code" -ne 0 ]; then echo -e "${RED}✗ Cannot stash changes for transfer.${ENDCOLOR}" echo "$stash_output" exit 1 fi echo -e "${YELLOW}Creating worktree at ${BOLD}${wip_path}${NORMAL}${YELLOW} on branch ${BOLD}${wip_branch}${NORMAL}...${ENDCOLOR}" - local add_output - add_output=$(git worktree add -b "$wip_branch" "$wip_path" HEAD 2>&1) - if [ $? -ne 0 ]; then + local add_output add_code add_progress_shown + stream_or_capture_git add_output add_code add_progress_shown \ + git worktree add -b "$wip_branch" "$wip_path" HEAD + if [ "$add_code" -ne 0 ]; then echo -e "${RED}✗ Cannot create worktree.${ENDCOLOR}" echo "$add_output" # Restore the stash so the user does not lose work @@ -455,8 +459,8 @@ function wip_down_stash { echo local pop_output pop_code - pop_output=$(git stash pop "$wip_stash_ref" 2>&1) - pop_code=$? + stream_or_capture_git pop_output pop_code pop_progress_shown \ + git stash pop "$wip_stash_ref" if [ $pop_code -ne 0 ]; then echo -e "${RED}✗ Cannot restore WIP.${ENDCOLOR}" @@ -489,9 +493,9 @@ function wip_down_branch { echo -e "${YELLOW}Restoring WIP from ${BOLD}${wip_branch}${NORMAL}...${ENDCOLOR}" echo - local merge_output merge_code - merge_output=$(git merge --squash --no-commit "$wip_branch" 2>&1) - merge_code=$? + local merge_output merge_code merge_progress_shown + stream_or_capture_git merge_output merge_code merge_progress_shown \ + git merge --progress --squash --no-commit "$wip_branch" if [ $merge_code -ne 0 ]; then echo -e "${RED}✗ Cannot restore WIP — squash-merge failed.${ENDCOLOR}" @@ -568,9 +572,9 @@ function wip_down_worktree { fi # Squash-merge the wip branch into the current branch as uncommitted changes - local merge_output merge_code - merge_output=$(git merge --squash --no-commit "$wip_branch" 2>&1) - merge_code=$? + local merge_output merge_code merge_progress_shown + stream_or_capture_git merge_output merge_code merge_progress_shown \ + git merge --progress --squash --no-commit "$wip_branch" if [ $merge_code -ne 0 ]; then echo -e "${RED}✗ Cannot restore WIP — squash-merge failed.${ENDCOLOR}" echo "$merge_output" diff --git a/scripts/worktree.sh b/scripts/worktree.sh index b6d11b1..cf0ee11 100644 --- a/scripts/worktree.sh +++ b/scripts/worktree.sh @@ -407,9 +407,10 @@ function worktree_add { if [ "$source_mode" == "default" ]; then if [ -n "$origin_name" ]; then echo -e "${YELLOW}Fetching ${origin_name}/${main_branch}...${ENDCOLOR}" - local fetch_output - fetch_output=$(git fetch "$origin_name" "$main_branch" 2>&1) - if [ $? -ne 0 ]; then + local fetch_output fetch_code fetch_progress_shown + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress "$origin_name" "$main_branch" + if [ "$fetch_code" -ne 0 ]; then echo -e "${RED}✗ Cannot fetch ${main_branch}.${ENDCOLOR}" echo "$fetch_output" return 1 @@ -450,9 +451,9 @@ function worktree_add { echo echo -e "${YELLOW}Creating worktree at ${BOLD}${wt_path}${NORMAL}${YELLOW} with new branch ${BOLD}${wt_branch_name}${NORMAL}${YELLOW} from ${BOLD}${from_label}${NORMAL}${YELLOW}...${ENDCOLOR}" - local add_output - add_output=$(git worktree add -b "$wt_branch_name" "$wt_path" "$from_ref" 2>&1) - local add_code=$? + local add_output add_code add_progress_shown + stream_or_capture_git add_output add_code add_progress_shown \ + git worktree add -b "$wt_branch_name" "$wt_path" "$from_ref" echo if [ $add_code -eq 0 ]; then @@ -490,9 +491,9 @@ function worktree_add { echo echo -e "${YELLOW}Creating worktree at ${BOLD}${wt_path}${NORMAL}${YELLOW} for ${BOLD}${branch_name}${NORMAL}${YELLOW}...${ENDCOLOR}" - local add_output - add_output=$(git worktree add "$wt_path" "$branch_name" 2>&1) - local add_code=$? + local add_output add_code add_progress_shown + stream_or_capture_git add_output add_code add_progress_shown \ + git worktree add "$wt_path" "$branch_name" echo if [ $add_code -eq 0 ]; then @@ -521,9 +522,10 @@ function worktree_add { echo -e "${YELLOW}Fetching ${origin_name}...${ENDCOLOR}" echo - local fetch_output - fetch_output=$(git fetch "$origin_name" 2>&1) - if [ $? -ne 0 ]; then + local fetch_output fetch_code fetch_progress_shown + stream_or_capture_git fetch_output fetch_code fetch_progress_shown \ + git fetch --progress "$origin_name" + if [ "$fetch_code" -ne 0 ]; then echo -e "${RED}✗ Cannot fetch remote.${ENDCOLOR}" echo "$fetch_output" return 1 @@ -543,13 +545,14 @@ function worktree_add { echo echo -e "${YELLOW}Creating worktree at ${BOLD}${wt_path}${NORMAL}${YELLOW} tracking ${BOLD}${origin_name}/${branch_name}${NORMAL}${YELLOW}...${ENDCOLOR}" - local add_output + local add_output add_code add_progress_shown if git show-ref --verify --quiet "refs/heads/$branch_name" 2>/dev/null; then - add_output=$(git worktree add "$wt_path" "$branch_name" 2>&1) + stream_or_capture_git add_output add_code add_progress_shown \ + git worktree add "$wt_path" "$branch_name" else - add_output=$(git worktree add --track -b "$branch_name" "$wt_path" "$origin_name/$branch_name" 2>&1) + stream_or_capture_git add_output add_code add_progress_shown \ + git worktree add --track -b "$branch_name" "$wt_path" "$origin_name/$branch_name" fi - local add_code=$? echo if [ $add_code -eq 0 ]; then diff --git a/tests/test_common_utils.bats b/tests/test_common_utils.bats index 36fdb80..55a7a5a 100644 --- a/tests/test_common_utils.bats +++ b/tests/test_common_utils.bats @@ -234,3 +234,49 @@ teardown() { run is_network_error "error: failed to push some refs to 'origin'" [ "$status" -ne 0 ] } + +# ===== print_changes_stat ===== + +@test "print_changes_stat: prints file rows and summary" { + run print_changes_stat " a.txt | 3 ++- + b-c.txt | 1 - + 2 files changed, 2 insertions(+), 2 deletions(-)" + [ "$status" -eq 0 ] + [[ "$output" == *"a.txt"* ]] + [[ "$output" == *"b-c.txt"* ]] + [[ "${lines[${#lines[@]}-1]}" == "2 files changed, 2 insertions(+), 2 deletions(-)" ]] +} + +@test "print_changes_stat: limit collapses extra rows into a count" { + stat="" + for i in 1 2 3 4 5; do stat="${stat} f$i.txt | 1 +"$'\n'; done + stat="${stat} 5 files changed, 5 insertions(+)" + run print_changes_stat "$stat" 2 + [ "$status" -eq 0 ] + [[ "$output" == *"f2.txt"* ]] + [[ "$output" != *"f3.txt"* ]] + [[ "$output" == *"... and 3 more files"* ]] + [[ "$output" == *"5 files changed"* ]] +} + +@test "print_changes_stat: create mode rows stay after the summary" { + run print_changes_stat " new.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 new.txt" + [ "$status" -eq 0 ] + [[ "${lines[1]}" == "1 file changed, 1 insertion(+)" ]] + [[ "${lines[2]}" == *"create mode 100644 new.txt"* ]] +} + +@test "print_changes_stat: does not color hyphens in file names" { + GREEN_ES="" RED_ES="" ENDCOLOR_ES="" run print_changes_stat " my-file.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-)" + [[ "$output" == *"my-file.txt"* ]] + [[ "$output" == *"+-"* ]] +} + +@test "print_changes_stat: empty input prints nothing" { + run print_changes_stat "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/tests/test_fetch.bats b/tests/test_fetch.bats index 0acca1f..d66b5f2 100644 --- a/tests/test_fetch.bats +++ b/tests/test_fetch.bats @@ -123,3 +123,18 @@ push_from_second_clone() { [ "$status" -ne 0 ] [[ "$output" =~ "No git remote configured" ]] } + +# ===== pull (fast-forward) shares the fetch helper ===== + +@test "pull: fast-forward prints the change stat once, without git's raw output" { + setup_remote_repo + push_from_second_clone + + run pull "main" "origin" "" "" "" + [ "$status" -eq 0 ] + [[ "$output" == *"Pulled using fast-forward"* ]] + [[ "$output" == *"incoming.txt"* ]] + [[ "$output" == *"1 file changed"* ]] + [[ "$output" != *"Fast-forward"* ]] + [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] +} diff --git a/tests/test_stream_capture.bats b/tests/test_stream_capture.bats index 7c94f4c..58daec3 100644 --- a/tests/test_stream_capture.bats +++ b/tests/test_stream_capture.bats @@ -88,3 +88,65 @@ setup() { [ ! -s "$shown_out" ] grep -q "fatal" "$capture" } + +# ===== spinner / progress helpers ===== + +@test "strip_git_progress: drops \\r-updated progress meters, keeps messages" { + local cr=$'\r' + out=$(strip_git_progress "remote: Counting objects: 50% (1/2)${cr}remote: Counting objects: 100% (2/2), done. +Receiving objects: 100% (2/2), done. +From /tmp/remote + * branch main -> FETCH_HEAD +Updating files: 100% (3/3), done.") + [ "$out" = "From /tmp/remote + * branch main -> FETCH_HEAD" ] +} + +@test "strip_git_progress: keeps errors and remote messages" { + out=$(strip_git_progress "remote: Create a pull request for 'feat': +error: failed to push some refs") + [[ "$out" == *"Create a pull request"* ]] + [[ "$out" == *"failed to push some refs"* ]] +} + +@test "_progress_label: maps git commands to readable labels" { + [ "$(_progress_label git fetch --progress origin)" = "Fetching" ] + [ "$(_progress_label git push --progress)" = "Pushing" ] + [ "$(_progress_label git switch --progress main)" = "Checking out files" ] + [ "$(_progress_label git stash pop)" = "Applying stash" ] + [ "$(_progress_label git stash push -m x)" = "Stashing" ] + [ "$(_progress_label git gc)" = "Running git gc" ] +} + +@test "run_with_spinner: without a TTY passes stdout and exit code through" { + slow_fn() { echo "result"; return 4; } + code=0 + out=$(run_with_spinner "Waiting" slow_fn) || code=$? + [ "$out" = "result" ] + [ "$code" = "4" ] +} + +@test "quiet window: label without a TTY draws no spinner" { + capture="$BATS_TEST_TMPDIR/capture" + err_out="$BATS_TEST_TMPDIR/err" + bash -c 'sleep 1.2; echo done' >"$capture" 2>&1 & + revealed="" + _stream_transfer_after_delay $! "$capture" 100 revealed "Fetching" 2> "$err_out" >/dev/null + [ -z "$revealed" ] + [ ! -s "$err_out" ] +} + +@test "_git_may_prompt_for_credentials: https remote without a helper may prompt" { + repo="$BATS_TEST_TMPDIR/repo" + git init -q "$repo" + cd "$repo" + git remote add origin https://example.com/x/y.git + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + unset GIT_ASKPASS GIT_TERMINAL_PROMPT + origin_name=origin + _git_may_prompt_for_credentials git fetch --progress origin + ! GIT_TERMINAL_PROMPT=0 _git_may_prompt_for_credentials git fetch origin + ! _git_may_prompt_for_credentials git switch main + git config credential.helper store + ! _git_may_prompt_for_credentials git push --progress origin main +}