Skip to content

Instantly share code, notes, and snippets.

@larryv
Last active August 29, 2015 14:04
Show Gist options
  • Save larryv/b251a63672ecba1afb3f to your computer and use it in GitHub Desktop.
Save larryv/b251a63672ecba1afb3f to your computer and use it in GitHub Desktop.
#!/usr/bin/env zsh
emulate -R zsh # Reset (most) options to defaults.
setopt EXTENDED_GLOB
##### APPETIZERS (or: preliminary setup) #####
#
# Adapted from git's "git-sh-setup".
function die { die_with_status 1 "$@"; }
function die_with_status {
local _status=$1
shift
printf >&2 '%s\n' "$*"
exit $_status
}
function require_clean_work_tree {
git rev-parse --verify HEAD > /dev/null || exit 1
git update-index -q --ignore-submodules --refresh
git diff-files --quiet --ignore-submodules ||
local UNSTAGED=yes
git diff-index --cached --quiet --ignore-submodules HEAD -- ||
local UNCOMMITTED=yes
case $UNSTAGED,$UNCOMMITTED in
yes,)
printf >&2 "Cannot $1: You have unstaged changes.\n" ;;
,yes)
printf >&2 "Cannot $1: Your index contains uncommitted changes.\n" ;;
yes,yes)
printf >&2 "Cannot $1: You have unstaged changes. "
printf >&2 "Additionally, your index contains uncommitted changes.\n"
;;
esac
if [[ -n $UNSTAGED || -n $UNCOMMITTED ]]
then
[[ -n $2 ]] && printf >&2 "%s\n" $2
exit 1
fi
}
function parse_ident_from_commit {
# TODO: Worry about encodings and junk.
# TODO: Worry about single quotes in the input.
typeset -A fields
fields=("$@")
(( ${#fields} == 0 )) && return
while read field data && [[ -n $field ]]
do
[[ -n ${upper::=$fields[$field]} ]] || continue
name=${data% <*}
email=${${data##* <}%> *}
date=${data##*> }
#printf >&2 'name %s email %s date %s\n' $name $email $date
printf "GIT_%s_NAME='%s'\n" $upper $name
printf "GIT_%s_EMAIL='%s'\n" $upper $email
printf "GIT_%s_DATE='%s'\n" $upper $date
done
}
# Normalize environment a bit.
unset cdpath # Make sure "cd" works normally.
unset IFS # Make sure field splitting works normally.
# Make sure we're in a git repository.
GIT_DIR="$(git rev-parse --git-dir)" || exit
[[ -n $GIT_DIR ]] && GIT_DIR="$(cd -q $GIT_DIR && pwd)" ||
die 'Unable to determine absolute path of git directory.'
: ${GIT_OBJECT_DIRECTORY=$GIT_DIR/objects}
##### MAIN COURSE (or: main logic) #####
#
# Adapted from git's "git-filter-branch".
function warn {
printf >&2 "%s\n" "$*"
}
function finish_ident {
# Use default value if id name is missing.
printf ": \${GIT_$1_NAME:=\${GIT_$1_EMAIL%%%%@*}}\n"
printf "export GIT_$1_NAME GIT_$1_EMAIL GIT_$1_DATE\n"
}
function set_ident {
parse_ident_from_commit author AUTHOR committer COMMITTER
finish_ident AUTHOR
finish_ident COMMITTER
}
# Make sure the repo is clean.
if [[ "$(git rev-parse --is-bare-repository)" == false ]]
then
require_clean_work_tree 'rewrite branches'
fi
# Parameters.
tempdir=.git-rewrite
filter_tag_name=cat
orig_namespace=refs/original/
force=
remap_to_ancestor=
# Custom author/committer-rewriting logic.
typeset -A authors
while read author eq name_email
do
name=${name_email% <*}
email=${${name_email##* <}:0:-1}
authors[$author]="$name $email"
done
filter_env='
auth=$authors[$GIT_AUTHOR_NAME]
if [[ -n $auth ]]
then
name=${auth% *}
email=${auth##* }
export GIT_AUTHOR_NAME=$name GIT_AUTHOR_EMAIL=$email \
GIT_COMMITTER_NAME=$name GIT_COMMITTER_EMAIL=$email
fi
'
# TODO: Support more command-line arguments. For now, edit parameters
# directly.
while :
do
case $1 in
--force|-f)
shift
force=t
continue
;;
*)
break
;;
esac
done
# Check for leftovers from aborted git-filter-branch operation.
case $force in
t)
rm -fR $tempdir
;;
'')
[[ -d $tempdir ]] && die "$tempdir already exists, please remove it."
;;
esac
# Set up working directory.
orig_dir="$(pwd)"
mkdir -p $tempdir/t &&
tempdir="$(cd $tempdir && pwd)" &&
cd $tempdir/t &&
workdir="$(pwd)" ||
die ''
trap 'cd $orig_dir && rm -fR $tempdir' 0
# Save the environment.
ORIG_GIT_DIR=$GIT_DIR
ORIG_GIT_WORK_TREE=$GIT_WORK_TREE
ORIG_GIT_INDEX_FILE=$GIT_INDEX_FILE
GIT_WORK_TREE=.
export GIT_DIR GIT_WORK_TREE
# Make sure orig_namespace is empty.
git for-each-ref |
while read sha1 type name
do
case $force,$name in
,$orig_namespace*)
die "Cannot create a new backup.
A previous backup already exists in $orig_namespace
Force overwriting the backup with -f"
;;
t,$orig_namespace*)
git update-ref -d $name $sha1
;;
esac
done || exit
# Update refs if their heads were rewritten.
git rev-parse --no-flags --revs-only --symbolic-full-name \
--default HEAD "$@" | sed '/^^/d' > $tempdir/heads
[[ -s $tempdir/heads ]] || die 'Which ref do you want to rewrite?'
export GIT_INDEX_FILE="$(pwd)/../index"
# Map old->new commit IDs for rewriting parents.
typeset -A map
nonrevs="$(git rev-parse --no-revs "$@")" || exit
[[ -n $nonrevs ]] && remap_to_ancestor=t
# Stash rev arguments and save non-rev arguments as positional parameters.
parse="$(git rev-parse --revs-only "$@")"
eval set -- "$(git rev-parse --sq --no-revs "$@")"
# Enumerate the commits to be rewritten.
git rev-list --reverse --topo-order --default HEAD \
--parents --simplify-merges --stdin "$@" <<< $parse > ../revs ||
die 'Could not get the commits.'
commits="$(wc -l < ../revs | tr -d ' ')"
(( commits == 0 )) && die 'Found nothing to rewrite.'
# SHOW TIME
(( git_filter_branch__commit_count = 0 ))
while read commit parents
do
(( git_filter_branch__commit_count += 1 ))
printf "\rRewrite $commit ($git_filter_branch__commit_count/$commits)"
GIT_ALLOW_NULL_SHA1=1 git read-tree -i -m $commit ||
die 'Could not initialize the index.'
export GIT_COMMIT=$commit
commit_data="$(git cat-file commit $commit)" ||
die 'Cannot read commit $commit.'
eval "$(set_ident <<< $commit_data)" ||
die "Setting author/committer failed for commit $commit"
eval "$filter_env" < /dev/null ||
die "env filter failed: $filter_env"
parentstr=()
for parent in $parents; do for reparent in ${map[$parent]:-$parent}
do
[[ -z $parentstr[(re)$reparent] ]] &&
parentstr=($parentstr -p $reparent)
done; done
message=${commit_data#*
}
map[$commit]="$(git commit-tree "$(git write-tree)" "$parentstr[@]"
<<< $message)" ||
die 'Could not write rewritten commit.'
done < ../revs
# TODO: Support path filtering.
printf "\n"
while read ref
do
[[ -f $orig_namespace$ref ]] && continue
sha1="$(git rev-parse "$ref^0")"
rewritten=${map[$sha1]:-$sha1}
[[ $sha1 == $rewritten ]] &&
warn "WARNING: Ref '$ref' is unchanged." && continue
case $rewritten in
'')
printf "Ref '$ref' was deleted.\n"
git update-ref -m 'rewrite-authors: delete' -d $ref $sha1 ||
die "Could not delete $ref."
;;
[0-9a-f](#c40))
printf "Ref '$ref' was rewritten.\n"
if ! git update-ref -m 'rewrite-authors: rewrite' \
$ref $rewritten $sha1 2> /dev/null
then
if [[ "$(git cat-file -t $ref)" == tag ]]
then
[[ -z $filter_tag_name ]] &&
warn "WARNING: You said to rewrite tagged commits, but not the corresponding tag." &&
warn "WARNING: Perhaps use '--tag-name-filter cat' to rewrite the tag."
else
die "Could not rewrite $ref."
fi
fi
;;
*)
warn "WARNING: '$ref' was rewritten into multiple commits:"
warn $rewritten
warn "WARNING: Ref '$ref' points to the first one now."
rewritten=${rewritten%%
*}
git update-ref -m 'rewrite-authors: rewrite to first' \
$ref $rewritten $sha1 ||
die "Could not rewrite $ref."
;;
esac
git update-ref -m 'rewrite-authors: backup' $orig_namespace$ref $sha1 ||
exit
done < $tempdir/heads
# TODO: Support tag rewriting.
##### DESSERT (or: cleanup) #####
#
# Adapted from git's "git-filter-branch".
cd $orig_dir
rm -fR $tempdir
trap - 0
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
[[ -n $ORIG_GIT_DIR ]] && export GIT_DIR=$ORIG_GIT_DIR
[[ -n $ORIG_GIT_WORK_TREE ]] && export GIT_WORK_TREE=$ORIG_GIT_WORK_TREE
[[ -n $ORIG_GIT_INDEX_FILE ]] && export GIT_INDEX_FILE=$ORIG_GIT_INDEX_FILE
if [[ "$(git rev-parse --is-bare-repository)" == false ]]
then
git read-tree -u -m HEAD || exit
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment