Skip to content

Instantly share code, notes, and snippets.

@cormacrelf
Last active November 10, 2022 18:30
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cormacrelf/5ac5a9f949fa1d8d2d85e5e9eedcf045 to your computer and use it in GitHub Desktop.
Save cormacrelf/5ac5a9f949fa1d8d2d85e5e9eedcf045 to your computer and use it in GitHub Desktop.
A tool for operating on your git index, without stashing: git index-exec 'cargo fmt'

git-index-exec

A tool for operating on your git index, without stashing. It is common to want to commit only part of your working directory, but also be sure that each commit will compile if checked out, or has correctly formatted code. This tool checks out your current index to a temporary directory (stable for each unique git working dir PWD) and runs a shell snippet in it. It then takes any changes that command made, and applies them to the index.

Hint: the 'index' is also known as the staging area. It's where the content of a file goes when you git add it, or use a GUI or interactive tool to stage pieces of a file. The index is what goes into a commit when you run git commit.

Installation

To install, drop the script below named git-index-exec anywhere in your $PATH, and run chmod +x /path/to/git-index-exec. A good place is ~/.local/bin, or in your dotfiles' bin directory if you have one.

Examples:

Check that your code compiles before committing.

git index-exec 'cargo check'

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 less noisy diffs.

git index-exec 'cargo fmt'

Fix the last commit's code formatting (you can run this even if your working directory is not clean!):

git index-exec 'cargo fmt' && git commit --amend -C HEAD

Or whack this in your .gitconfig

[alias]
	amend = "!f() { ! git diff --staged --exit-code >/dev/null && git commit --amend -C HEAD; }; f"
	amend-fmt = "!f() { git index-exec 'cargo fmt' && git amend; }; f"

Edit your index in the temporary directory, write back on exit after viewing the diff and confirming.

git index-exec nvim --confirm

Motivation

I built this because I often find myself writing for slightly too long without committing, and wanting to commit it in smaller pieces. The big problem for running a code formatter on those smaller pieces is that those tools only operate on working directories. So you need a way to represent the index as a normal directory structure. The existing git workflows to accomplish this are just too much overhead:

  • git stash -k makes my unstaged changes disappear from view, when I was busy adding them piecemeal to the index. A stash works great if you have written half of a new commit that you don't want to commit yet. Not so well if you have written two commits' worth of material, and are now moving things in and out of the index.
  • Committing and then splitting via git rebase -i is great, but it's a lot of commands to remember and execute in the right order, including editing an interactive rebase plan. The workflow also requires you to create a commit first, which means:
    • invariably you have to come up with a temporary commit message. "Temp". Great.
    • and either: commit the in-progress index and the rest separately, giving you the same problem as stash where the rest of the changes are whisked away; or
    • or: commit altogether, and lose your progress creating a commit-worthy index.

This tool is much easier to use in many light-weight cases.

A good tip for a small 'edit compile test loop' when working on the index is to use something like vim-fugitive's :Gdiffsplit where each save is written directly into the index.

If you are doing a lot of edits... simply git index-exec $EDITOR --confirm. You can then launch an editor, save things, and exit to finish.

#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment