|
#!/usr/bin/env bash |
|
|
|
# git-index-exec |
|
# Tested with git version 2.35.1 |
|
# |
|
# Author: Cormac Relf |
|
# Last Modified: 2022-11 |
|
# License: GPL-2.0-only |
|
|
|
set -uo pipefail |
|
|
|
CLEAR='' RED='' YELLOW='' GREEN='' CYAN=''; |
|
if [[ -t 0 ]] || [[ -t 1 ]]; then |
|
# shellcheck disable=SC2034 |
|
CLEAR='\033[0m' RED='\033[0;31m' YELLOW='\033[0;33m' GREEN='\033[0;32m' CYAN='\033[0;36m'; |
|
fi |
|
|
|
bail() { |
|
MESSAGE="$1" |
|
STATUS=${2-1} |
|
echo -e "${RED}git-index-exec: ${MESSAGE}${CLEAR}" >/dev/stderr |
|
exit "$STATUS" |
|
} |
|
|
|
bail_git() { |
|
WORKTREE_ROOT="$1" |
|
MSG="${2-}" |
|
if [ -n "$MSG" ]; then echo -e "${RED}${MSG}${CLEAR}" > /dev/stderr; fi |
|
echo -e "${RED}git-index-exec: worktree appears to be broken. |
|
You may wish to try one of these: |
|
git worktree repair; |
|
rm -rf $WORKTREE_ROOT && git worktree prune |
|
${CLEAR}" >/dev/stderr |
|
exit 1 |
|
} |
|
|
|
bail_ok() { |
|
MESSAGE="$1" |
|
$VERBOSE && echo -e "${CLEAR}git-index-exec: exiting: ${MESSAGE}${CLEAR}" &>/dev/stderr |
|
exit 0 |
|
} |
|
|
|
confirm() { |
|
MESSAGE="$1" |
|
echo -ne "${YELLOW}git-index-exec: ${MESSAGE}${CLEAR} (yes/[no]) " &>/dev/stderr |
|
read -r response &>/dev/stderr |
|
if ! [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then |
|
echo "git-index-exec: exiting without writing changes" &>/dev/stderr |
|
exit |
|
fi |
|
} |
|
|
|
# get a stable alternate worktree to work in |
|
# that way your tools will cache stuff and get to reuse it next time. |
|
# e.g. cargo's 'target' directory. |
|
ORIG_WORKTREE=$(git rev-parse --show-toplevel) || exit 1 |
|
# for consistent hashing |
|
ORIG_WORKTREE=$(realpath "$ORIG_WORKTREE") |
|
PWD=$(realpath "$PWD") |
|
SUBDIRECTORY=${PWD#"$ORIG_WORKTREE"} |
|
HEAD=$(git rev-parse HEAD) || bail "repository has no HEAD ref" |
|
HASH=$(echo "$ORIG_WORKTREE" | sha256sum | cut -b -10) |
|
# realpath because git worktree runs realpath |
|
TEMP=$(realpath "/tmp/index-exec-$HASH") |
|
|
|
# ORIG_GITDIR=$(git rev-parse --resolve-git-dir "$ORIG_WORKTREE/.git") |
|
|
|
# for warnings in create_worktree |
|
OUTPUT_DEVICE=/dev/null |
|
FLAG_WORKTREE_ROOT=false |
|
|
|
|
|
check_recursive() { |
|
# try to resolve whether $PWD is already an index-exec worktree. |
|
# if so, we'll error out so as not to nest them. |
|
# there's no reason why you can't create an index-exec worktree for a _regular_ |
|
# worktree. so we'll use a marker file in the resolved .git dir. |
|
ORIG_GITDIR=$(git rev-parse --resolve-git-dir "$ORIG_WORKTREE/.git") || bail_git "$PWD" |
|
if [[ -d "$ORIG_GITDIR/net.cormacrelf.git-index-tree" ]]; then |
|
if $FLAG_WORKTREE_ROOT; then |
|
echo "$ORIG_WORKTREE" |
|
exit 0 |
|
else |
|
bail "you're already in a git-index-tree worktree" |
|
fi |
|
fi |
|
} |
|
|
|
create_worktree() { |
|
# create a worktree in the temp dir, with a detached HEAD |
|
if ! [[ -d "$TEMP" ]]; then |
|
git worktree add -f -d "$TEMP" HEAD &>$OUTPUT_DEVICE || bail_git "$TEMP" "failed to add worktree" |
|
else |
|
if ! git worktree list --porcelain | grep "^worktree $TEMP\$" -A2 &>$OUTPUT_DEVICE; then |
|
bail_git "$TEMP" |
|
fi |
|
if ! (unset GIT_INDEX_FILE && cd "$TEMP" && git checkout -f -q "$HEAD" &>$OUTPUT_DEVICE); then |
|
bail_git "$TEMP" |
|
fi |
|
if $VERBOSE; then |
|
echo "checked out $HEAD in worktree" > $OUTPUT_DEVICE |
|
fi |
|
fi |
|
WORKTREE_GITDIR=$(git rev-parse --resolve-git-dir "$TEMP/.git") || bail_git "$TEMP" |
|
mkdir -p "$WORKTREE_GITDIR/net.cormacrelf.git-index-tree/" |
|
echo "$ORIG_WORKTREE" > "$WORKTREE_GITDIR/net.cormacrelf.git-index-tree/origdir" |
|
} |
|
|
|
GIT_PARSEOPT_SPEC="\ |
|
git index-exec '<command>' [<arg>...] [--] [<pathspec>...] |
|
|
|
Checks out your current index to a temporary directory (stable for each unique |
|
git working dir) and runs a shell snippet in it. It then takes any changes that |
|
command made, and applies them to the index. |
|
|
|
The canonical example usage is a code formatter that formats all files. |
|
|
|
The command is executed from the current working directory relative to the |
|
repository root, but in the worktree. If <pathspec>s are provided, they are |
|
also relative to the PWD, but you can use e.g. :/ to re-centre. |
|
|
|
Using <pathspec> limits the scope of the operation to the paths matched. You |
|
can therefore filter the modifications made by a command. Changes not matched |
|
by any <pathspec> are discarded. |
|
-- |
|
Arguments |
|
h,help Show the help |
|
v,verbose Show more output |
|
c,confirm Show a confirmation before writing any changes back |
|
n,dry-run Stop short of writing back to the index |
|
d,diff Show a diff only; implies --dry-run |
|
|
|
Diff controls, for scripting and e.g. pre-commit hooks |
|
quiet Like 'git diff --quiet'; implies --exit-code |
|
exit-code Like 'git diff --exit-code' |
|
worktree-root Prints the worktree root and exits. |
|
" |
|
|
|
examples() { |
|
echo "\ |
|
Examples |
|
git index-exec 'cargo fmt' |
|
Run a code formatter on the index. Ensures that a commit has correct code |
|
formatting. If you also run the code formatter on your working directory, |
|
you will see much cleaner diffs of what you have changed. |
|
|
|
Since 'cargo fmt' operates on the entire crate, this will mean if you |
|
commit the resulting index, it will pass 'cargo fmt --check' when you |
|
check out that commit. |
|
|
|
git index-exec 'cargo check' |
|
Check that your staged code compiles before committing. git-index-exec |
|
will not delete any ignored files, so it will not start from scratch when |
|
you run it again. |
|
|
|
git index-exec 'bash' --confirm |
|
Open a shell in the index-exec worktree, which is somewhere in /tmp. This |
|
lets you basically edit your index by hand. When you're done, you will be |
|
shown a diff and asked if you want the changes written to your index. |
|
|
|
git index-exec 'cargo fmt' --diff -- tests |
|
Check your staged changes' formatting, and show a diff of the tests/ |
|
directory only. |
|
" > /dev/stderr |
|
} |
|
|
|
parse_opts() { |
|
echo "$GIT_PARSEOPT_SPEC" | git rev-parse --parseopt -- "$@" || echo 'examples && exit' |
|
} |
|
|
|
eval "$(parse_opts "$@")" |
|
|
|
DRY_RUN=false DRY_RUN_FLAG="" |
|
flag_dry_run() { DRY_RUN=true; DRY_RUN_FLAG="--dry-run"; } |
|
|
|
VERBOSE=false OUTPUT_DEVICE=/dev/null |
|
flag_verbose() { VERBOSE=true; OUTPUT_DEVICE="/dev/stderr"; } |
|
|
|
CONFIRM=false NO_PAGER_FLAG="" |
|
flag_confirm() { CONFIRM=true; NO_PAGER_FLAG="--no-pager"; } |
|
|
|
DIFF=false; PATHSPECS=() |
|
flag_diff() { DIFF=true; } |
|
|
|
QUIET=false QUIET_FLAG="" |
|
flag_quiet() { QUIET=true; QUIET_FLAG="--quiet"; } |
|
|
|
EXIT_CODE=false EXIT_CODE_FLAG="" |
|
flag_exit_code() { EXIT_CODE=true; EXIT_CODE_FLAG="--exit-code"; } |
|
|
|
FLAG_WORKTREE_ROOT=false |
|
flag_worktree_root() { FLAG_WORKTREE_ROOT=true; } |
|
|
|
CMD="" |
|
args_rest=() |
|
while [ -n "$*" ]; do |
|
arg="$1" |
|
case "$arg" in |
|
-h) eval "$(parse_opts -h)"; exit;; |
|
--) shift; CMD="${1-}"; shift; args_rest=("$@"); break;; |
|
-n) flag_dry_run;; |
|
-v) flag_verbose;; |
|
-c) flag_confirm;; |
|
-d) flag_diff;; |
|
--quiet) flag_quiet;; |
|
--exit-code) flag_exit_code;; |
|
--worktree-root) flag_worktree_root; check_recursive; echo "$TEMP"; create_worktree; exit $?;; |
|
esac |
|
shift |
|
done |
|
|
|
check_recursive |
|
|
|
PATHSPECS=("${args_rest[@]}") |
|
|
|
if [[ -z "$CMD" ]]; then |
|
bail "no command specified: use git index-exec 'command args args && more' args..." |
|
fi |
|
if $QUIET || $EXIT_CODE; then |
|
flag_diff |
|
fi |
|
if $DIFF; then |
|
# just to make sure |
|
flag_dry_run |
|
fi |
|
|
|
# echo "CMD: ""$(git rev-parse --sq-quote "$CMD")" |
|
# echo "DRY_RUN: " "$DRY_RUN" |
|
# echo "VERBOSE: " "$VERBOSE" |
|
# echo "CONFIRM: " "$CONFIRM" |
|
# echo "DIFF: " "$DIFF" |
|
# echo "PATHSPECS:""$(git rev-parse --sq-quote "${PATHSPECS[@]}")" |
|
# exit |
|
|
|
INDEX_TREE=$(git write-tree) |
|
|
|
# we might be called from within a pre-commit hook. |
|
# In that case, GIT_INDEX_FILE is set to `.git/index` which will cause errors |
|
# when we do git operations on the worktree, as the worktree just has a file |
|
# called `.git` containing a pointer to "$ORIG_WORKTREE"'s .git/worktrees/... folder. |
|
# |
|
# if `git commit -a` was used, GIT_INDEX_FILE is set to a temporary index including the files that -a would add. |
|
# However we will be working on a worktree, in which `.git/index` |
|
# |
|
# So we need to save and restore this env var. |
|
# Technically there are other ones (GIT_AUTHOR_DATE, GIT_EXEC_PATH, GIT_AUTHOR_EMAIL/NAME, GIT_EDITOR=:, GIT_PREFIX) |
|
# But we don't care about those. The commit we generate below is only used to have a nice tree to read out. |
|
TMP_GIF="${GIT_INDEX_FILE-}" |
|
unset GIT_INDEX_FILE |
|
|
|
# create a worktree in the temp dir, with a detached HEAD |
|
if ! [ -d "$TEMP" ]; then |
|
git worktree add -f -d "$TEMP" HEAD || bail_git "$TEMP" "failed to add worktree" |
|
else |
|
if ! git worktree list --porcelain | grep "^worktree $TEMP\$" -A2 &>$OUTPUT_DEVICE; then |
|
bail_git "$TEMP" |
|
fi |
|
if ! (unset GIT_INDEX_FILE && cd "$TEMP" && git checkout -f -q "$HEAD" &>$OUTPUT_DEVICE); then |
|
bail_git "$TEMP" |
|
fi |
|
if $VERBOSE; then |
|
echo "checked out $HEAD in worktree" > $OUTPUT_DEVICE |
|
fi |
|
fi |
|
|
|
|
|
# overwrite the index in the index-tree worktree. |
|
# -u will also write the files, including deleting ones we deleted in our |
|
# index. |
|
cd "$TEMP" || bail "failed to cd $TEMP" |
|
git checkout -f "$INDEX_TREE" -- :/ \ |
|
|| bail "failed to checkout tree $INDEX_TREE into worktree directory" |
|
|
|
# run the user's command |
|
sh -c "$CMD" |
|
STATUS=$? |
|
|
|
# subdir has a slash at the start, if it's non-empty. |
|
cd ".$SUBDIRECTORY" || bail "failed to cd to .$SUBDIRECTORY in worktree" |
|
|
|
if $DIFF; then |
|
# do it again with --quiet, because using --exit-code prevents using |
|
# custom diff pagers via git config. |
|
# git diff --quiet > /dev/null |
|
|
|
if ! git $NO_PAGER_FLAG diff $QUIET_FLAG $EXIT_CODE_FLAG -- "${PATHSPECS[@]}"; then |
|
exit 1 |
|
fi |
|
fi |
|
|
|
if $VERBOSE && ! $CONFIRM; then |
|
echo "git-index-exec: diff compared to index:" > $OUTPUT_DEVICE |
|
! git diff --stat --exit-code -- "${PATHSPECS[@]}" > $OUTPUT_DEVICE || echo " no changes" > $OUTPUT_DEVICE |
|
fi |
|
if [ "$STATUS" -ne 0 ]; then |
|
$VERBOSE && echo "git-index-exec: '$CMD' exited with status $STATUS" > $OUTPUT_DEVICE |
|
exit $STATUS |
|
fi |
|
if git diff --quiet -- "${PATHSPECS[@]}"; then |
|
bail_ok "no changes to index recorded" |
|
fi |
|
|
|
if $DIFF; then |
|
exit |
|
fi |
|
|
|
if $CONFIRM; then |
|
if ! $DIFF; then |
|
git --no-pager diff -- "${PATHSPECS[@]}" &>/dev/stderr |
|
fi |
|
echo &>/dev/stderr |
|
echo "git-index-exec: diff compared to index:" &>/dev/stderr |
|
! git diff --stat --exit-code -- "${PATHSPECS[@]}" &>/dev/stderr || echo " no changes" &>/dev/stderr |
|
confirm "write these changes to your index?" |
|
fi |
|
|
|
# add everything in the pathspecs (or everywhere) |
|
# pathspecs not beginning with :/ will be relative paths. |
|
git add --all -- "${PATHSPECS[@]}" > $OUTPUT_DEVICE |
|
cd "$TEMP" || bail "failed to cd back to worktree root at $TEMP" |
|
# remove changes that weren't just added |
|
git checkout . > $OUTPUT_DEVICE |
|
# write a tree which we will read into the main index later |
|
if ! NEW_TREE=$(git write-tree); then |
|
bail "unable to write a tree object from worktree's changes" |
|
fi |
|
|
|
cd "$ORIG_WORKTREE" || bail "failed to cd $ORIG_WORKTREE" |
|
# restore saved, so we can write into the right one even if it's using a temporary index |
|
GIT_INDEX_FILE="$TMP_GIF" |
|
|
|
if $VERBOSE && ! $DRY_RUN; then |
|
echo "writing $NEW_TREE into index" > $OUTPUT_DEVICE |
|
elif $VERBOSE && $DRY_RUN; then |
|
echo "dry run: simulating writing $NEW_TREE into index" > $OUTPUT_DEVICE |
|
fi |
|
|
|
# read the new head's tree into the index of $ORIG_WORKTREE |
|
# without -u, we don't write the tree into the working directory. |
|
if ! git read-tree -m -i --aggressive $DRY_RUN_FLAG "$NEW_TREE"; then |
|
bail "failed to read index-exec's changes back into your index" |
|
fi |