-
Notifications
You must be signed in to change notification settings - Fork 6
/
git-subhistory.sh
executable file
·377 lines (333 loc) · 12 KB
/
git-subhistory.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
#!/bin/bash
# http://github.com/laughinghan/git-subhistory
# Request bash to report that a pipeline failed if any of the commands
# in the pipe failed (not just the last one). This is necessary for
# proper error handling.
set -o pipefail
# util fn (at the top 'cos used in options parsing)
die () {
echo "fatal:" "$@" >&2
exit 1
}
######################
# Options Parsing
# >:( so many lines
# if zero args, default to -h
test $# = 0 && set -- -h
OPTS_SPEC="\
git-subhistory split <subproj-path> (-b | -B) <subproj-branch>
git-subhistory merge <subproj-path> (-a) <subproj-branch>
--
q,quiet be quiet
v,verbose be verbose
a,auto use auto-generated commit message without prompting user
h show the help
options for 'split':
b= create a new branch for the split-out commit history
B= like -b but force creation"
eval "$(echo "$OPTS_SPEC" | git rev-parse --parseopt -- "$@" || echo exit $?)"
# ^ this is actually what you're supposed to do, see `git rev-parse --help`
quiet="$GIT_QUIET"
verbose=
newbranch=
force_newbranch=
auto=
while test $# != 0
do
case "$1" in
-q|--quiet) quiet=1 ;;
--no-quiet) quiet= ;;
-v|--verbose) verbose=1 ;;
--no-verbose) verbose= ;;
-a|--auto) auto=1 ;;
--no-auto) auto= ;;
-b|-B)
test "$1" = "-B" && force_newbranch=-f
shift
newbranch="$1"
test "$newbranch" || die "branch name must be nonempty"
;;
--) break ;;
esac
shift
done
shift
##############
# Logging Fns
if test "$quiet"
then
say () {
:
}
say_stdin () {
cat >/dev/null
}
else
say () {
echo "$@" >&2
}
say_stdin () {
cat >&2
}
fi
if test "$verbose" -a ! "$quiet"
then
elaborate () {
echo "$@" >&2
}
else
elaborate () {
:
}
fi
usage () {
echo "$@" >&2
echo >&2
exec "$0" -h
}
# TODO: find a better place to put this
# Get "path/to/sub/" (relative to toplevel) from <subproj-path> (relative to
# original current working directory).
# Bonus: assimilate needs .'s and //'s normalized away, trailing / guaranteed
get_path_to_sub () {
test "$1" || usage "first arg <subproj-path> is required (just . is allowed)"
path_to_sub="$(cd "./$GIT_PREFIX/$1" && git rev-parse --show-prefix)" || exit $?
}
##############
# Subcommands
# TODO: find a better place to put this
commit_filter='git commit-tree "$@"' # default/noop
subhistory_split () {
test $# = 1 || usage "wrong number of arguments to 'split'"
get_path_to_sub "$1"
elaborate "'split' path_to_sub='$path_to_sub' newbranch='$newbranch'" \
"force_newbranch='$force_newbranch'"
# setup SPLIT_HEAD
if test "$newbranch"
then
git branch "$newbranch" $force_newbranch || exit $?
split_head="$(git rev-parse --symbolic-full-name "$newbranch")"
git symbolic-ref SPLIT_HEAD "$split_head" || exit $?
elaborate "Created/reset branch $newbranch (symref-ed as SPLIT_HEAD)"
else
git update-ref --no-deref SPLIT_HEAD HEAD || exit $?
elaborate "Set detached SPLIT_HEAD"
fi
git filter-branch \
--original subhistory-tmp/filter-branch-backup \
--subdirectory-filter "$path_to_sub" \
--commit-filter "$commit_filter" \
-- SPLIT_HEAD \
2>&1 | say_stdin || exit $?
git update-ref --no-deref SPLIT_HEAD SPLIT_HEAD || exit $?
elaborate 'un-symref-ed SPLIT_HEAD'
say
say "Split out history of $path_to_sub to $(
if test "$newbranch"
then
echo "$newbranch (also SPLIT_HEAD)"
else
echo "SPLIT_HEAD"
fi
)"
}
subhistory_assimilate () {
# args
test $# = 2 || usage "wrong number of arguments to '$subcommand'"
get_path_to_sub "$1" # FIXME requires path/to/sub/ to already exist :(
assimilatee="$2"
git update-ref ASSIMILATE_HEAD "$assimilatee" || exit $?
elaborate "'assimilate' path_to_sub='$path_to_sub' assimilatee='$assimilatee'" \
"ASSIMILATE_HEAD='$(git rev-parse ASSIMILATE_HEAD)'"
# test if "path/to/sub/" has git history yet
if test "$(git ls-tree --name-only HEAD "${path_to_sub%/}")"
then
# split HEAD
mkdir "$GIT_DIR/subhistory-tmp/split-to-orig-map" || exit $?
commit_filter='
rewritten=$(git commit-tree "$@") &&
echo $GIT_COMMIT > "$GIT_DIR/subhistory-tmp/split-to-orig-map/$rewritten" &&
echo $rewritten'
subhistory_split "$1" || exit $?
say # blank line after summary of subhistory_split
# build the synthetic commits on top of the original Main commits, by
# filtering for parents that were splits and swapping them out for their
# originals
parent_filter='
for parent in $(cat)
do
test $parent != -p \
&& cat "$GIT_DIR/subhistory-tmp/split-to-orig-map/$parent" 2>/dev/null \
|| echo $parent
done'
# write synthetic commits that make the same changes as the Sub commits but
# to the subtree of Main, by rewriting each Sub commit as having the same tree
# as either the original Main commit the Sub commit's parent was split from or
# the new synthetic parent commit that's been rewritten as a Main commit, but
# with the subtree overwritten.
# - Complication: there can be >1 parent, with different Main trees. Luckily,
# differences in Main trees could only possibly come from Main commits that
# are ancestors of HEAD [FN1], which must have been merged at some point in
# the history of HEAD, since HEAD itself is a single commit. Finding the
# earliest such merge that won't conflict with HEAD is nontrivial, since the
# same two commits could be merged in any number of commits with any tree at
# all. Leave finding the earliest merged tree as TODO, for now if all parent
# Main trees are the same use that [FN2], otherwise just use the default
# Main tree guaranteed not to merge conflict with HEAD [FN3].
# + [FN1]: others weren't split into ancestors of SPLIT_HEAD, and hence
# aren't in the split-to-orig-map, and thus couldn't be a rewritten
# parent. This is actually why merge explicitly doesn't invert splitting
# of all commits, it only inverts splitting of ancestors of HEAD, at the
# cost of the potentially surprising cherry-pick-like commits described
# in the README.
# + [FN2]: augh, this takes more than a dozen lines: looping over each
# parent, read the tree for the (rewritten) parent into the index, delete
# "path/to/sub/" from the index, and then if this is the first parent,
# set a variable to the hash of the index, else check that the hash of
# the index matches the stored hash (use default Main tree if not).
# + [FN3]: if there's multiple merge bases between SPLIT_HEAD and
# $assimilatee, just use HEAD (can't conflict with itself). However, if
# there's a unique merge base between SPLIT_HEAD and $assimilatee, the
# Main commit it was split from (as an ancestor of SPLIT_HEAD, musta' been
# split from something) will be *the* merge base between HEAD and
# ASSIMILATE_HEAD when they're merged, so its tree is guaranteed not to
# result in merge conflicts (empty diff against itself, after all), and is
# an ancestor of HEAD so hopefully is closer to when $assimilatee actually
# diverged from the subhistory of Sub in HEAD. In fact, this does exactly
# the right thing in a common use case: say Sub is some subhistory in Main
# (split out or merged in, doesn't matter), and after some changes to Main
# a new Sub commit is split out and merged into Sub, and then commits are
# added on top of that merge commit that we want to merge back into Main.
# We're going to have to assimilate a merge commit whose parents' Main
# trees are different ("some changes to Main" happened between them), but
# one is a descendant of the other so there's an obvious right merge base
# Main tree, which is exactly what we use.
merge_base="$(git merge-base SPLIT_HEAD $assimilatee)"
default_Main_tree=$(test $(echo "$merge_base" | wc -l) = 1 \
&& cat "$GIT_DIR/subhistory-tmp/split-to-orig-map/$merge_base" \
|| echo HEAD)
index_filter='
if git rev-parse --verify -q $GIT_COMMIT^2 >/dev/null # if this is a merge
then
for parent in $(git rev-list --no-walk --parents $GIT_COMMIT \
| cut -f 2- -d " ") # first word is just $GIT_COMMIT
do
git read-tree $(
cat "$GIT_DIR/subhistory-tmp/split-to-orig-map/$parent" 2>/dev/null \
|| map $parent) &&
git rm --cached -r --ignore-unmatch '"'$path_to_sub'"' -q &&
if test -z $parent_Main_tree
then
parent_Main_tree=$(git write-tree)
elif test $(git write-tree) != $parent_Main_tree
then
git read-tree '$default_Main_tree' && # TODO: find earliest merged tree
git rm --cached -r --ignore-unmatch '"'$path_to_sub'"' -q &&
break
fi
done
elif git rev-parse --verify -q $GIT_COMMIT^ >/dev/null # if this rev has a parent (IE is not a root/initial commit)
then
parent=$(git rev-parse $GIT_COMMIT^) &&
git read-tree $(
cat "$GIT_DIR/subhistory-tmp/split-to-orig-map/$parent" 2>/dev/null \
|| map $parent)
git rm --cached -r --ignore-unmatch '"'$path_to_sub'"' -q
fi &&
git read-tree --prefix='"'$path_to_sub'"' $GIT_COMMIT'
revs_to_rewrite=SPLIT_HEAD..ASSIMILATE_HEAD
else
parent_filter=
index_filter='
git read-tree --empty &&
git read-tree --prefix='"'$path_to_sub'"' $GIT_COMMIT'
revs_to_rewrite=ASSIMILATE_HEAD
fi
commit_count=$(git rev-list --count $revs_to_rewrite)
if test $commit_count -ne 0
then
git filter-branch \
--original subhistory-tmp/filter-branch-backup \
--parent-filter "$parent_filter" \
--index-filter "$index_filter" \
-- $revs_to_rewrite \
2>&1 | say_stdin || exit $?
say
say "Assimilated $assimilatee into $(
git symbolic-ref --short HEAD -q \
|| echo "detached HEAD ($(git rev-parse --short HEAD))"
) under $path_to_sub as ASSIMILATE_HEAD"
else
say
say "Already completely assimilated - nothing to do"
git update-ref ASSIMILATE_HEAD HEAD || exit $?
fi
}
subhistory_merge () {
edit_option=$(test $auto || echo "--edit")
mkdir -p "./$GIT_PREFIX/$1" &&
subhistory_assimilate "$@" &&
say &&
git merge ASSIMILATE_HEAD $edit_option -m "$(
echo "$(merge_name "$2" | git fmt-merge-msg \
| sed 's/^Merge /Merge subhistory /') under $path_to_sub"
)" \
2>&1 | say_stdin
}
# # # # # #
# Util Fn, only used in one place, whose functionality really should be part of
# a git utility but isn't, and had to be copied from the git source code.
# As part of generating merge commit messages, belongs in fmt-merge-msg, but
# had to be copied from contrib/examples/git-merge.sh (latest master branch):
# https://github.com/git/git/blob/932f7e47699993de0f6ad2af92be613994e40afe/contrib/examples/git-merge.sh#L140-L171
merge_name () {
remote="$1"
rh=$(git rev-parse --verify "$remote^0" 2>/dev/null) || return
if truname=$(expr "$remote" : '\(.*\)~[0-9]*$') &&
git show-ref -q --verify "refs/heads/$truname" 2>/dev/null
then
echo "$rh branch '$truname' (early part) of ."
return
fi
if found_ref=$(git rev-parse --symbolic-full-name --verify \
"$remote" 2>/dev/null)
then
expanded=$(git check-ref-format --branch "$remote") ||
exit
if test "${found_ref#refs/heads/}" != "$found_ref"
then
echo "$rh branch '$expanded' of ."
return
elif test "${found_ref#refs/remotes/}" != "$found_ref"
then
echo "$rh remote branch '$expanded' of ."
return
fi
fi
if test "$remote" = "FETCH_HEAD" -a -r "$GIT_DIR/FETCH_HEAD"
then
sed -e 's/ not-for-merge / /' -e 1q \
"$GIT_DIR/FETCH_HEAD"
return
fi
echo "$rh commit '$remote'"
}
#######
# Main
subcommand="$1"
shift
case "$subcommand" in
split|assimilate|merge) ;;
*) usage "unknown subcommand '$subcommand'" ;;
esac
# All subcommands need:
# the original current working directory prefix (named like for git aliases)
GIT_PREFIX="$(git rev-parse --show-prefix)"
# to be at toplevel (for filter-branch); need ./ in case of empty string
cd ./$(git rev-parse --show-cdup) || exit $?
# the path to the .git directory (or directory to use as such)
GIT_DIR="$(git rev-parse --git-dir)"
# a temporary directory for e.g. filter-branch backups
mkdir -p "$GIT_DIR/subhistory-tmp/" || exit $?
trap "rm -rf '$GIT_DIR/subhistory-tmp/'" EXIT
"subhistory_$subcommand" "$@"