Skip to content

Instantly share code, notes, and snippets.

@sbellware
Last active March 22, 2026 02:31
Show Gist options
  • Select an option

  • Save sbellware/6c800099eb2cd92c7ca9a353cfded9bb to your computer and use it in GitHub Desktop.

Select an option

Save sbellware/6c800099eb2cd92c7ca9a353cfded9bb to your computer and use it in GitHub Desktop.
Interactive Git staging and commit, with diffing
#!/usr/bin/env bash
# Copyright (c) 2026 Scott Bellware. All rights reserved.
#
# git-stage — interactively select changed files to stage and commit.
#
# Controls:
# ↑ / ↓ / k / j Navigate
# Space Toggle selection
# a Select / deselect all
# d Show diff of file under cursor
# Enter Confirm — stage selected, unstage deselected, then commit
# q / Ctrl-C Quit (index left exactly as-is)
#
# Already-staged files appear pre-checked.
# Unchecking a staged file will unstage it on confirm.
# NOTE: intentionally no 'set -e' — bash arithmetic (( expr )) exits non-zero
# when the result is 0, which -e would treat as a fatal error.
set -uo pipefail
# ── Colours & formatting ────────────────────────────────────────────────────
bold() { printf '\033[1m%s\033[0m' "$*"; }
dim() { printf '\033[2m%s\033[0m' "$*"; }
green() { printf '\033[32m%s\033[0m' "$*"; }
yellow() { printf '\033[33m%s\033[0m' "$*"; }
cyan() { printf '\033[36m%s\033[0m' "$*"; }
red() { printf '\033[31m%s\033[0m' "$*"; }
reverse() { printf '\033[7m%s\033[27m' "$*"; }
rev_grn() { printf '\033[42;30m%s\033[0m' "$*"; }
# ── Sanity checks ───────────────────────────────────────────────────────────
if ! git rev-parse --is-inside-work-tree &>/dev/null; then
red "Error: not inside a git repository." >&2; echo; exit 1
fi
# ── Collect changed files ───────────────────────────────────────────────────
mapfile -t STATUS_LINES < <(git status --porcelain -u)
if [[ ${#STATUS_LINES[@]} -eq 0 ]]; then
echo "$(green '✓') Nothing to stage — working tree is clean."; exit 0
fi
# Parallel arrays
declare -a XY PATHS WAS_STAGED SEL
for line in "${STATUS_LINES[@]}"; do
xy="${line:0:2}"
path="${line:3}"
x="${xy:0:1}"
XY+=("$xy")
PATHS+=("$path")
# Pre-check files that are already in the index
if [[ "$x" != " " && "$x" != "?" ]]; then
WAS_STAGED+=(1); SEL+=(1)
else
WAS_STAGED+=(0); SEL+=(0)
fi
done
N=${#PATHS[@]}
cursor=0
scroll=0
# ── Terminal setup ──────────────────────────────────────────────────────────
LINES_DRAWN=0
hide_cursor() { tput civis 2>/dev/null || true; }
show_cursor() { tput cnorm 2>/dev/null || true; }
clear_drawn() {
local i
for (( i=0; i<LINES_DRAWN; i++ )); do
tput cuu1 2>/dev/null || true
tput el 2>/dev/null || true
done
}
OLD_STTY=$(stty -g)
restore() {
show_cursor
stty "$OLD_STTY" 2>/dev/null || true
}
trap 'restore; echo' EXIT
trap 'restore; echo; echo "$(dim Interrupted.)"; exit 130' INT
stty -echo -icanon min 1 time 0
hide_cursor
# ── Status label ────────────────────────────────────────────────────────────
status_label() {
local xy="$1" x="${1:0:1}" y="${1:1:1}"
# Untracked is a whole-file state — handle before the two-column logic
if [[ "$xy" == "??" ]]; then
cyan 'untracked'; return
fi
local parts=()
case "$x" in
A) parts+=("$(green 'added')") ;;
M) parts+=("$(green 'staged')") ;;
D) parts+=("$(green 'staged-del')") ;;
R) parts+=("$(green 'renamed')") ;;
C) parts+=("$(green 'copied')") ;;
esac
case "$y" in
M) parts+=("$(yellow 'modified')") ;;
D) parts+=("$(red 'deleted')") ;;
esac
local IFS='+'; printf '%s' "${parts[*]}"
}
staged_hint() {
local x="${1:0:1}"
if [[ "$x" != " " && "$x" != "?" ]]; then
printf '%s' "$(dim ' [staged — uncheck to unstage]')"
fi
}
# ── sel_count helper ─────────────────────────────────────────────────────────
count_selected() {
local c=0 i
for (( i=0; i<N; i++ )); do
[[ "${SEL[$i]}" == "1" ]] && c=$(( c + 1 ))
done
printf '%d' "$c"
}
# ── Draw ─────────────────────────────────────────────────────────────────────
draw() {
clear_drawn
local term_rows
term_rows=$(tput lines 2>/dev/null || echo 24)
local visible=$(( term_rows - 6 ))
[[ $visible -lt 1 ]] && visible=1
# scroll tracking
[[ $cursor -lt $scroll ]] && scroll=$cursor
[[ $cursor -ge $(( scroll + visible )) ]] && scroll=$(( cursor - visible + 1 ))
local sel_count
sel_count=$(count_selected)
local out="" lc=0
out+="$(bold ' git-stage') $(dim "— $N file(s) changed, $sel_count selected")"$'\n'
out+="$(dim ' ↑↓ navigate Space toggle d diff a all Enter confirm q quit')"$'\n'
out+="$(dim ' ────────────────────────────────────────────────────────────')"$'\n'
lc=$(( lc + 3 ))
local i
for (( i=0; i<visible; i++ )); do
local fi=$(( i + scroll ))
[[ $fi -ge $N ]] && break
local path="${PATHS[$fi]}"
local xy="${XY[$fi]}"
local check="[ ] "
[[ "${SEL[$fi]}" == "1" ]] && check="[✓] "
local lbl hint
lbl="$(status_label "$xy")"
hint="$(staged_hint "$xy")"
local row=" ${check} ${path} ${lbl}${hint}"
if [[ "${SEL[$fi]}" == "1" && $fi -eq $cursor ]]; then
out+="$(rev_grn "$row")"$'\n'
elif [[ $fi -eq $cursor ]]; then
out+="$(reverse "$row")"$'\n'
elif [[ "${SEL[$fi]}" == "1" ]]; then
out+="$(green "$row")"$'\n'
else
out+="${row}"$'\n'
fi
lc=$(( lc + 1 ))
done
local remaining=$(( N - visible - scroll ))
if [[ $remaining -gt 0 ]]; then
out+="$(dim " … $remaining more below")"$'\n'
lc=$(( lc + 1 ))
fi
out+="$(dim ' ────────────────────────────────────────────────────────────')"$'\n'
lc=$(( lc + 1 ))
printf '%s' "$out"
LINES_DRAWN=$lc
}
# ── Event loop ────────────────────────────────────────────────────────────────
while true; do
draw
# Read directly in the main shell (no subshell) so stty raw mode is inherited
key=""
IFS= read -r -s -n1 key || true
if [[ "$key" == $'\x1b' ]]; then
seq=""
IFS= read -r -s -n2 -t 0.1 seq || true
key="${key}${seq}"
fi
case "$key" in
$'\x1b[A'|k)
[[ $cursor -gt 0 ]] && cursor=$(( cursor - 1 ))
;;
$'\x1b[B'|j)
[[ $cursor -lt $(( N - 1 )) ]] && cursor=$(( cursor + 1 ))
;;
' ')
if [[ "${SEL[$cursor]}" == "1" ]]; then SEL[$cursor]=0
else SEL[$cursor]=1; fi
;;
a|A)
sel_count=$(count_selected)
if [[ $sel_count -eq $N ]]; then
for (( i=0; i<N; i++ )); do SEL[$i]=0; done
else
for (( i=0; i<N; i++ )); do SEL[$i]=1; done
fi
;;
d|D)
# Show diff for the file under the cursor, then restore raw mode
stty "$OLD_STTY"
show_cursor
path="${PATHS[$cursor]}"
xy="${XY[$cursor]}"
x="${xy:0:1}"
# Staged files: diff against HEAD; unstaged/untracked: diff worktree
if [[ "$x" != " " && "$x" != "?" ]]; then
git diff --color=always --word-diff --unified=5 --cached -- "$path" | less -R
elif [[ "$xy" == "??" ]]; then
less "$path"
else
git diff --color=always --word-diff --unified=5 -- "$path" | less -R
fi
tput clear
stty -echo -icanon min 1 time 0
hide_cursor
LINES_DRAWN=0
;;
$'\n'|$'\r'|'')
break
;;
q|Q|$'\x1b'|$'\x03')
clear_drawn
echo "$(dim 'Quit — index left unchanged.')"
exit 0
;;
esac
done
# ── Apply staging changes ─────────────────────────────────────────────────────
clear_drawn
declare -a TO_STAGE=() UNSTAGE_NEW=() UNSTAGE_TRACKED=()
for (( i=0; i<N; i++ )); do
if [[ "${SEL[$i]}" == "1" && "${WAS_STAGED[$i]}" == "0" ]]; then
TO_STAGE+=("${PATHS[$i]}")
elif [[ "${SEL[$i]}" == "0" && "${WAS_STAGED[$i]}" == "1" ]]; then
# 'A' = newly added, no HEAD ref yet; must use rm --cached
# M/D/R/C = tracked file with a HEAD ref; use restore --staged
if [[ "${XY[$i]:0:1}" == "A" ]]; then
UNSTAGE_NEW+=("${PATHS[$i]}")
else
UNSTAGE_TRACKED+=("${PATHS[$i]}")
fi
fi
done
if [[ $(( ${#UNSTAGE_NEW[@]} + ${#UNSTAGE_TRACKED[@]} )) -gt 0 ]]; then
echo "$(bold 'Unstaging:')"
for f in "${UNSTAGE_NEW[@]}" "${UNSTAGE_TRACKED[@]}"; do echo " $(red '−') $f"; done
[[ ${#UNSTAGE_NEW[@]} -gt 0 ]] && git rm --cached -q -- "${UNSTAGE_NEW[@]}"
[[ ${#UNSTAGE_TRACKED[@]} -gt 0 ]] && git restore --staged -- "${UNSTAGE_TRACKED[@]}"
echo
fi
if [[ ${#TO_STAGE[@]} -gt 0 ]]; then
echo "$(bold 'Staging:')"
for f in "${TO_STAGE[@]}"; do echo " $(green '+') $f"; done
git add -- "${TO_STAGE[@]}"
echo
fi
# Determine what's staged now
declare -a NOW_STAGED=()
for (( i=0; i<N; i++ )); do
[[ "${SEL[$i]}" == "1" ]] && NOW_STAGED+=("${PATHS[$i]}")
done
if [[ ${#NOW_STAGED[@]} -eq 0 ]]; then
echo "$(yellow 'Nothing staged — aborting commit.')"
exit 0
fi
if [[ ${#TO_STAGE[@]} -eq 0 && $(( ${#UNSTAGE_NEW[@]} + ${#UNSTAGE_TRACKED[@]} )) -eq 0 ]]; then
echo "$(dim 'Staging unchanged.')"
echo
fi
# ── Restore terminal before text input ───────────────────────────────────────
stty "$OLD_STTY"
# ── Commit message ────────────────────────────────────────────────────────────
printf "$(bold 'Commit message') $(dim '(blank to abort):')\n › "
IFS= read -r commit_msg
if [[ -z "$commit_msg" ]]; then
echo "$(yellow 'No message given — files are staged but not committed.')"
exit 0
fi
# ── Commit ────────────────────────────────────────────────────────────────────
echo
if git commit -m "$commit_msg"; then
echo
echo "$(green '✓') Committed successfully."
else
echo
echo "$(red 'Commit failed — files remain staged.')"
exit 1
fi
@sbellware
Copy link
Author

Here's a brief demo video:

git-stage-demo.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment