Skip to content

Instantly share code, notes, and snippets.

@drmingdrmer
Last active November 25, 2020 05:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save drmingdrmer/aadec82ccc43c25b9a1a40fcaa96f7e9 to your computer and use it in GitHub Desktop.
Save drmingdrmer/aadec82ccc43c25b9a1a40fcaa96f7e9 to your computer and use it in GitHub Desktop.
Split one git history into two
#!/bin/bash
usage()
{
cat <<-END
Split one git history into two.
One of them only contains changes of specified files.
The other contains other changes(or optionally contains full history).
Usage:
git-split [ -b <to-new-branch> ] [-r] [-m] [-s <prefix> ] <from>..[<to>] -- <fns>
Synoposis:
Initial:
* 358aee4 (HEAD) fix css
| css/site.css | 13 ++---
|
* f869f40 init site
| html/index.html | 12 ++--
| html/header.html | 34 +-----
| css/site.css | 61 +++++++------
|
* 82554be fix doc of Build
To split folder html into a separate branch:
git-split -m 82554be..HEAD -- html/
Results in:
* 948149a Merge commit 'aa76f4de5be5ddbb2908ba145fea5697c7baf728' into HEAD
|\\
| * aa76f4d init site
| | html/index.html | 12 ++--
| | html/header.html | 34 +-----
| |
* | 088e903 fix css
| | css/site.css | 13 ++---
| |
* | 0b606c2 init site
| | css/site.css | 61 +++++++------
|/
* 82554be fix doc of Build
Arguments:
<from> and <to> specifies history range to split.
E.g. to split the latest 3 commit:
git-split master~3..master
"--" must present as a separator.
<fns> are several file patterns.
E.g.:
git-split master~3..master -- src/foo.* deps/config.yaml
Options:
-b <new-branch> Create a new branch.
When absent, create a temporary branch "tmpbranch".
-o Create an orphan branch instead of one based on <from>.
-r Remove <fns> from original history.
Without -r, the original history will not be modified.
-m Merge back action:
"" for doing nothing;
"merge": merge the new branch to commmit "<to>";
"rebase": rebase "<to>" onto the new branch.
non empty -m implies -r.
-s <prefix> Strip <prefix> dir segments from paths that are splitted out.
E.g.:
git-split -s 3 base..master -- my/foo/dir/bar.txt
removes "my/foo/dir".
END
}
clr()
{
# clr light read blabla
local black=0
local white=7
local red=1
local green=2
local brown=3
local blue=4
local purple=5
local cyan=6
local light=""
local color=$1
shift
if [ "$color" == "light" ]; then
light="tput bold; "
color=$1
shift
fi
local code=$(eval 'echo $'$color)
local cmd="${light}tput setaf $code"
local color_str="$(eval "$cmd")"
echo $color_str"$@""$(tput sgr0)"
}
shlib_init_colors()
{
Black="$( tput setaf 0)"
BlackBG="$( tput setab 0)"
DarkGrey="$( tput bold; tput setaf 0)"
LightGrey="$( tput setaf 7)"
LightGreyBG="$( tput setab 7)"
White="$( tput bold; tput setaf 7)"
Red="$( tput setaf 1)"
RedBG="$( tput setab 1)"
LightRed="$( tput bold; tput setaf 1)"
Green="$( tput setaf 2)"
GreenBG="$( tput setab 2)"
LightGreen="$( tput bold; tput setaf 2)"
Brown="$( tput setaf 3)"
BrownBG="$( tput setab 3)"
Yellow="$( tput bold; tput setaf 3)"
Blue="$( tput setaf 4)"
BlueBG="$( tput setab 4)"
LightBlue="$( tput bold; tput setaf 4)"
Purple="$( tput setaf 5)"
PurpleBG="$( tput setab 5)"
Pink="$( tput bold; tput setaf 5)"
Cyan="$( tput setaf 6)"
CyanBG="$( tput setab 6)"
LightCyan="$( tput bold; tput setaf 6)"
NC="$( tput sgr0)" # No Color
}
screen_width()
{
local chr="${1--}"
chr="${chr:0:1}"
local width=$(tput cols 2||echo 80)
width="${COLUMNS:-$width}"
echo $width
}
hr()
{
# generate a full screen width horizontal ruler
local width=$(screen_width)
printf -vl "%${width}s\n" && echo ${l// /$chr};
}
remove_color()
{
# remove color control chars from stdin or first argument
local sed=gsed
which -s $sed || sed=sed
local s="$1"
if [ -z "$s" ]; then
$sed -r "s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[mGK]//g"
else
echo "$s" | remove_color
fi
}
text_hr()
{
# generate a full screen width sperator line with text.
# text_hr "-" "a title"
# > a title -----------------------------------------
#
# variable LR=l|m|r controls alignment
local chr="$1"
shift
local bb="$(echo "$@" | remove_color)"
local text_len=${#bb}
local width=$(screen_width)
let width=width-text_len
local lr=${LR-m}
case $lr in
m)
let left=width/2
let right=width-left
echo "$(printf -vl "%${left}s\n" && echo ${l// /$chr})$@$(printf -vl "%${right}s\n" && echo ${l// /$chr})"
;;
r)
echo "$(printf -vl "%${width}s\n" && echo ${l// /$chr})$@"
;;
*)
# l by default
echo "$@$(printf -vl "%${width}s\n" && echo ${l// /$chr})"
;;
esac
}
SHLIB_LOG_VERBOSE=1
SHLIB_LOG_FORMAT='[$(date +"%Y-%m-%d %H:%M:%S")] $level $title $mes'
die()
{
err "$@" >&2
exit 1
}
die_empty()
{
if test -z "$1"
then
shift
die empty: "$@"
fi
}
set_verbose()
{
SHLIB_LOG_VERBOSE=${1-1}
}
log()
{
local color="$1"
local title="$2"
local level="$_LOG_LEVEL"
shift
shift
local mes="$@"
local NC="$(tput sgr0)"
if [ -t 1 ]; then
title="${color}${title}${NC}"
level="${color}${level}${NC}"
fi
eval "echo \"$SHLIB_LOG_FORMAT\""
}
dd()
{
debug "$@"
}
debug()
{
if [ ".$SHLIB_LOG_VERBOSE" = ".1" ]; then
local LightCyan="$(tput bold ; tput setaf 6)"
_LOG_LEVEL=DEBUG log "$LightCyan" "$@" >&2
fi
}
info()
{
local Brown="$(tput setaf 3)"
_LOG_LEVEL=" INFO" log "$Brown" "$@"
}
ok() {
local Green="$(tput setaf 2)"
_LOG_LEVEL=" OK" log "${Green}" "$@"
}
err() {
local Red="$(tput setaf 1)"
_LOG_LEVEL="ERROR" log "${Red}" "$@"
}
git_hash()
{
git rev-parse $1 \
|| die "'git_hash $@'"
}
git_is_merge()
{
test $(git cat-file -p "$1" | grep "^parent " | wc -l) -gt 1
}
git_parents()
{
git rev-list --parents -n 1 ${1-HEAD} | { read self parents; echo $parents; }
}
git_rev_list()
{
# --parents
# print parent in this form:
# <commit> <parent-1> <parent-2> ..
git rev-list \
--reverse \
--topo-order \
--default HEAD \
--simplify-merges \
"$@" \
|| die "'git rev-list $@'"
}
git_tree_hash()
{
git rev-parse "$1^{tree}"
}
git_ver()
{
local git_version=$(git --version | awk '{print $NF}')
local git_version_1=${git_version%%.*}
local git_version_2=${git_version#*.}
git_version_2=${git_version_2%%.*}
printf "%03d%03d" $git_version_1 $git_version_2
}
git_working_root()
{
git rev-parse --show-toplevel
}
git_rev_exist()
{
git rev-parse --verify --quiet "$1" >/dev/null
}
git_branch_default_remote()
{
local branchname=$1
git config --get branch.${branchname}.remote
}
git_branch_default_upstream_ref()
{
local branchname=$1
git config --get branch.${branchname}.merge
}
git_branch_default_upstream()
{
git rev-parse --abbrev-ref --symbolic-full-name "$1"@{upstream}
# OR
# git_branch_default_upstream_ref "$@" | sed 's/^refs\/heads\///'
}
git_branch_exist()
{
git_rev_exist "refs/heads/$1"
}
git_head_branch()
{
git symbolic-ref --short HEAD
}
git_commit_date()
{
# git_commit_date author|commit <ref> [date-format]
# by default output author-date
local what_date="%ad"
if [ "$1" = "commit" ]; then
# commit date instead of author date
what_date="%cd"
fi
shift
local ref=$1
shift
local fmt="%Y-%m-%d %H:%M:%S"
if [ "$#" -gt 0 ]; then
fmt="$1"
fi
shift
git log -n1 --format="$what_date" --date=format:"$fmt" "$ref"
}
git_commit_copy()
{
# We're going to set some environment vars here, so
# do it in a subshell to get rid of them safely later
dd copy_commit "{$1}" "{$2}" "{$3}"
git log -1 --pretty=format:'%an%n%ae%n%ad%n%cn%n%ce%n%cd%n%s%n%n%b' "$1" |
(
read GIT_AUTHOR_NAME
read GIT_AUTHOR_EMAIL
read GIT_AUTHOR_DATE
read GIT_COMMITTER_NAME
read GIT_COMMITTER_EMAIL
read GIT_COMMITTER_DATE
export GIT_AUTHOR_NAME \
GIT_AUTHOR_EMAIL \
GIT_AUTHOR_DATE \
GIT_COMMITTER_NAME \
GIT_COMMITTER_EMAIL \
GIT_COMMITTER_DATE
# (echo -n "$annotate"; cat ) |
git commit-tree "$2" $3 # reads the rest of stdin
) || die "Can't copy commit $1"
}
git_object_type()
{
# $0 ref|hash
# output "commit", "tree" etc
git cat-file -t "$@" 2>/dev/null
}
git_object_add_by_commit_path()
{
# add an blob or tree object to target_path in index
# the object to add is specified by commit and path
local target_path="$1"
local src_commit="$2"
local src_path="$3"
local src_dir="$(dirname "$src_path")/"
local src_name="$(basename "$src_path")"
local src_treeish="$(git rev-parse "$src_commit:$src_dir")"
git_object_add_by_tree_name "$target_path" "$src_treeish" "$src_name"
}
git_object_add_by_tree_name()
{
# add an blob or tree object to target_path in index
local target_path="$1"
local src_treeish="$2"
local src_name="$3"
dd "arg: target_path: ($target_path) src_treeish: ($src_treeish) src_name: ($src_name)"
local target_dir="$(dirname $target_path)/"
local target_fn="$(basename $target_path)"
local treeish
if [ -z "$src_name" ] || [ "$src_name" = "." ] || [ "$src_name" = "./" ]; then
treeish="$src_treeish"
else
treeish=$(git ls-tree "$src_treeish" "$src_name" | awk '{print $3}')
if [ -z "$treeish" ]; then
die "source treeish not found: in tree: ($src_treeish) name: ($src_name)"
fi
fi
dd "hash of object to add is: $treeish"
if [ "$(git_object_type $treeish)" = "blob" ]; then
# the treeish imported is a file, not a dir
# first create a wrapper tree or replace its containing tree
dd "object to add is blob"
local dir_treeish
local target_dir_treeish="$(git rev-parse "HEAD:$target_dir")"
if [ -n "target_dir_treeish" ]; then
dir_treeish="$(git rev-parse "HEAD:$target_dir")"
dd "target dir presents: $target_dir"
else
dd "target dir absent"
dir_treeish=""
fi
treeish=$(git_tree_add_blob "$dir_treeish" "$target_fn" $src_treeish $src_name) || die create wrapper treeish
target_path="$target_dir"
dd "wrapper treeish: $treeish"
dd "target_path set to: $target_path"
else
dd "object to add is tree"
fi
git_treeish_add_to_prefix "$target_path" "$treeish"
}
git_treeish_add_to_prefix()
{
local target_path="$1"
local treeish="$2"
dd treeish content:
git ls-tree $treeish
git rm "$target_path" -r --cached || dd removing target "$target_path"
if [ "$target_path" = "./" ]; then
git read-tree "$treeish" \
|| die "read-tree $target_path $treeish"
else
git read-tree --prefix="$target_path" "$treeish" \
|| die "read-tree $target_path $treeish"
fi
}
git_tree_add_tree()
{
# output new tree hash in stdout
# treeish can be empty
local treeish="$1"
local target_fn="$2"
local item_hash="$3"
local item_name="$4"
{
if [ -n "$treeish" ]; then
git ls-tree "$treeish" \
| fgrep -v " $item_name"
fi
cat "040000 tree $item_hash $target_fn"
} | git mktree
}
git_tree_add_blob()
{
# output new tree hash in stdout
# treeish can be empty
local treeish="$1"
local target_fn="$2"
local blob_treeish="$3"
local blob_name="$4"
{
if [ -n "$treeish" ]; then
git ls-tree "$treeish" \
| fgrep -v " $target_fn"
fi
git ls-tree "$blob_treeish" "$blob_name" \
| awk -v target_fn="$target_fn" -F" " '{print $1" "target_fn}'
} | git mktree
}
git_workdir_save()
{
local index_hash=$(git write-tree)
# add modified file to index and read index tree
git add -u
local working_hash=$(git write-tree)
# restore index tree
git read-tree $index_hash
echo $index_hash $working_hash
}
git_workdir_load()
{
local index_hash=$1
local working_hash=$2
git_object_type $index_hash || die "invalid index hash: $index_hash"
git_object_type $working_hash || die "invalid workdir hash: $working_hash"
# First create a temp commit to restore working tree.
#
# git-read-index to index and git-reset does not work because deleted file in
# index does not apply to working tree.
#
# But there is an issue with this:
# git checkout --orphan br
# git_workdir_load
# would fails, because ORIG_HEAD is not a commit.
local working_commit=$(echo "x" | git commit-tree $working_hash) || die get working commit
git reset --hard $working_commit || die reset to tmp commit
git reset --soft ORIG_HEAD || die reset to ORIG_HEAD
git read-tree $index_hash || die "load saved index tree from $index_hash"
}
git_workdir_is_clean()
{
local untracked="$1"
if [ "$untracked" == "untracked" ]; then
[ -z "$(git status --porcelain)" ]
else
[ -z "$(git status --porcelain --untracked-files=no)" ]
fi
}
git_copy_commit()
{
git_commit_copy "$@"
}
git_diff_ln_new()
{
# output changed line number of a file: <from> <end>; inclusive:
# 27 28
# 307 309
# 350 350
#
# Usage:
#
# diff working tree with HEAD:
# git_diff_ln_new HEAD -- <fn>
#
# diff working tree with staged:
# git_diff_ln_new -- <fn>
#
# diff staged(cached) with HEAD:
# git_diff_ln_new --cached -- <fn>
#
# in git-diff output:
# for add lines:
# @@ -53 +72,8
#
# for remove lines:
# @@ -155 +179,0
git diff -U0 "$@" \
| grep '^@@' \
| awk '{
# @@ -155 +179,0
# $1 $2 $3
l = $3
gsub("^+", "", l)
# add default offset: ",1"
split(l",1", x, ",")
# inclusive line range:
x[2] = x[1] + x[2] - 1
# line remove format: @@ -155, +179,0
# do need to output line range for removed.
if (x[2] >= x[1]) {
print x[1] " " x[2]
}
}'
}
os_detect()
{
local os
case $(uname -s) in
Linux)
os=linux ;;
*[bB][sS][dD])
os=bsd ;;
Darwin)
os=mac ;;
*)
os=unix ;;
esac
echo $os
}
mac_ac_power_connection()
{
# Connected: (Yes|No)
system_profiler SPPowerDataType \
| sed '1,/^ *AC Charger Information:/d' \
| grep Connected:
}
mac_power()
{
# $0 is-battery exit code 0 if using battery.
# $0 is-ac-power exit code 0 if using ac power.
local cmd="$1"
local os=$(os_detect)
if [ "$os" != "mac" ]; then
err "not mac but: $os"
return 1
fi
case $cmd in
is-battery)
mac_ac_power_connection | grep -q No
;;
is-ac-power)
mac_ac_power_connection | grep -q Yes
;;
*)
err "invalid cmd: $cmd"
return 1
;;
esac
}
fn_match()
{
# $0 a.txt *.txt
case "$1" in
$2)
return 0
;;
esac
return 1
}
new_branch=tmpbranch
merge=""
remove=0
orphan=0
to_strip=0
while getopts "b:m:p:s:orh" opname; do
case $opname in
b)
new_branch=$OPTARG
;;
m)
merge=$OPTARG
remove=1
;;
o)
orphan=1
;;
r)
remove=1
;;
s)
to_strip="$OPTARG"
;;
h)
usage
exit 0
;;
[?])
echo "unknown option: ($opname)"
usage
exit 1
;;
esac
done
shift $(($OPTIND - 1))
if [ ".$2" != ".--" ]; then
usage
exit 1
fi
range=$1
shift
shift
fns="$@"
base=${range%..*}
if [ ".$base" = ".$range" ]; then
usage
exit 1
fi
tip=${range#*..}
if [ ".$tip" = "." ]; then
tip=HEAD
fi
head=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ ".$head" = "." ]; then
head=$(git rev-parse HEAD)
fi
tip_hash=$(git_hash $tip)
new_hash=
merged_hash=
extract()
{
local parent=$1
local tree=
local commit=
for c in $(git log --reverse $base..$tip_hash --format="%H" -- $fns); do
info "== to pick $fns from:"
git --no-pager log --color --stat --decorate --pretty=oneline --abbrev-commit -M -n1 $c
info "== END =="
# mes=$(git log -n1 --format="%B" $c)
# force prefix when diff, since .gitconfig could hide prefix
git diff --src-prefix="" --dst-prefix="" --binary $c~ $c -- $fns | git apply -p $to_strip --index
info "== after patch $c =="
git status --short
info "== END =="
# manually commit index
tree=$(git write-tree)
dd "tree to commit: $tree"
git ls-tree -r $tree
dd "end of tree"
dd parent: $parent
commit=$(git_copy_commit "$c" "$tree" "-p $parent")
dd "new commit:" $commit
git reset --hard $commit --
parent=$commit
done
return 0
}
build_new_branch()
{
info "== build_new_branch =="
git checkout --orphan $new_branch || die checkout --orpahn $new_branch
if [ "$orphan" = "1" ]; then
git reset --hard
git commit --allow-empty -m "init-root" || die commit init-root
else
info reset $new_branch to $base
git reset --hard $base --
fi
git clean -dxf || die clean -dxf
info "HEAD hash: $(git_hash HEAD)"
extract $(git_hash HEAD) \
&& new_hash=$(git_hash HEAD) \
&& \
if [ "$new_branch" = "tmpbranch" ]; then
git checkout --detach HEAD \
&& git branch -d tmpbranch
else
:
fi \
&& info "== new branch $new_branch hash is $new_hash == "
}
discard_changes()
{
info "== discard_changes =="
# git checkout would fail if one of the files does not exists.
# thus newly added file impedes the whole process.
# git checkout ab12345 -- nonexistent
# local script='for f in '"$fns"'; do git rm -r -f $f; git checkout '"$base"' -- $f; done'
local script='for f in '"$fns"'; do echo "== $f =="; git rm -r -f $f; git status -- $f >&2; git checkout '"$base"' -- $f; git status -- $f >&2; done'
git checkout --detach $tip_hash \
&& info "== discard from $tip ==" \
&& git filter-branch -f --prune-empty --tree-filter "$script" $base..HEAD \
&& [ $(git log $base..HEAD -- "$fns" | wc -l | awk '{print $1}') = 0 ] \
&& info "== confirmed that no changes through $base..HEAD"
git status --short
info "== END =="
info "== history =="
git --no-pager log --color --stat --decorate --pretty=oneline --abbrev-commit -M $base..HEAD
info "== END =="
}
merge_or_rebase()
{
local action=$1
if [ "$action" == "merge" ]; then
info "== merge splitted branch back to $tip ==" \
&& \
if [ ".$new_branch" = "tmpbranch" ]; then
git merge --commit --no-edit --no-ff $new_hash
else
git merge --commit --no-edit --no-ff $new_branch
fi \
&& merged_hash=$(git_hash HEAD) \
&& info "merged hash: $merged_hash"
elif [ "$action" == "rebase" ]; then
info "== rebase $tip onto $new_branch ($new_hash) =="
git rebase $new_hash || return 1
merged_hash=$(git_hash HEAD)
info "rebased hash: $merged_hash"
else
die "invalid action: ($action)"
fi
}
is_same_as_original()
{
info "== diff with original $tip =="
git diff $tip || return 1
ok "Current HEAD is same as $tip"
}
update_tip_ref()
{
info "remove tmp branch: refs/original/HEAD"
git update-ref -d refs/original/HEAD
# if it is a reference(branch, tag), not hash or HEAD
if [ ".$tip" = ".HEAD" ]; then
info "git update-ref $tip $merged_hash"
git update-ref $tip $merged_hash \
&& info "== $tip set to $merged_hash"
fi
if git rev-parse refs/heads/$tip >/dev/null 2>/dev/null; then
info "git update-ref refs/heads/$tip $merged_hash"
git update-ref refs/heads/$tip $merged_hash \
&& info "== $tip set to $merged_hash"
fi
}
info "split $fns from $range"
build_new_branch || die build_new_branch
if [ "$remove" = "0" ]; then
exit 0;
fi
discard_changes || die discard_changes
if [ "$merge" = "" ]; then
exit 0
fi
merge_or_rebase "$merge" || die merge_or_rebase
is_same_as_original || die is_same_as_original
update_tip_ref || die update_tip_ref
git checkout $tip >/dev/null 2>/dev/null || die git checkout $tip
info "== Done =="
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment