summaryrefslogtreecommitdiff
path: root/git-filter-branch.sh
blob: 3772951aa714bc136b55a247137255bb49f00d61 (plain)
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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
#!/bin/sh
#
# Rewrite revision history
# Copyright (c) Petr Baudis, 2006
# Minimal changes to "port" it to core-git (c) Johannes Schindelin, 2007
#
# Lets you rewrite GIT revision history by creating a new branch from
# your current branch by applying custom filters on each revision.
# Those filters can modify each tree (e.g. removing a file or running
# a perl rewrite on all files) or information about each commit.
# Otherwise, all information (including original commit times or merge
# information) will be preserved.
#
# The command takes the new branch name as a mandatory argument and
# the filters as optional arguments. If you specify no filters, the
# commits will be recommitted without any changes, which would normally
# have no effect and result with the new branch pointing to the same
# branch as your current branch. (Nevertheless, this may be useful in
# the future for compensating for some Git bugs or such, therefore
# such a usage is permitted.)
#
# WARNING! The rewritten history will have different ids for all the
# objects and will not converge with the original branch. You will not
# be able to easily push and distribute the rewritten branch. Please do
# not use this command if you do not know the full implications, and
# avoid using it anyway - do not do what a simple single commit on top
# of the current version would fix.
#
# Always verify that the rewritten version is correct before disposing
# the original branch.
#
# Note that since this operation is extensively I/O expensive, it might
# be a good idea to do it off-disk, e.g. on tmpfs. Reportedly the speedup
# is very noticeable.
#
# OPTIONS
# -------
# -d TEMPDIR:: The path to the temporary tree used for rewriting
#	When applying a tree filter, the command needs to temporary
#	checkout the tree to some directory, which may consume
#	considerable space in case of large projects. By default it
#	does this in the '.git-rewrite/' directory but you can override
#	that choice by this parameter.
#
# Filters
# ~~~~~~~
# The filters are applied in the order as listed below. The COMMAND
# argument is always evaluated in shell using the 'eval' command.
# The $GIT_COMMIT environment variable is permanently set to contain
# the id of the commit being rewritten. The author/committer environment
# variables are set before the first filter is run.
#
# A 'map' function is available that takes an "original sha1 id" argument
# and outputs a "rewritten sha1 id" if the commit has been already
# rewritten, fails otherwise; the 'map' function can return several
# ids on separate lines if your commit filter emitted multiple commits
# (see below).
#
# --env-filter COMMAND:: The filter for modifying environment
#	This is the filter for modifying the environment in which
#	the commit will be performed. Specifically, you might want
#	to rewrite the author/committer name/email/time environment
#	variables (see `git-commit` for details). Do not forget to
#	re-export the variables.
#
# --tree-filter COMMAND:: The filter for rewriting tree (and its contents)
#	This is the filter for rewriting the tree and its contents.
#	The COMMAND argument is evaluated in shell with the working
#	directory set to the root of the checked out tree. The new tree
#	is then used as-is (new files are auto-added, disappeared files
#	are auto-removed - .gitignore files nor any other ignore rules
#	HAVE NO EFFECT!).
#
# --index-filter COMMAND:: The filter for rewriting index
#	This is the filter for rewriting the Git's directory index.
#	It is similar to the tree filter but does not check out the
#	tree, which makes it much faster. However, you must use the
#	lowlevel Git index manipulation commands to do your work.
#
# --parent-filter COMMAND:: The filter for rewriting parents
#	This is the filter for rewriting the commit's parent list.
#	It will receive the parent string on stdin and shall output
#	the new parent string on stdout. The parent string is in
#	format accepted by `git commit-tree`: empty for initial
#	commit, "-p parent" for a normal commit and "-p parent1
#	-p parent2 -p parent3 ..." for a merge commit.
#
# --msg-filter COMMAND:: The filter for rewriting commit message
#	This is the filter for rewriting the commit messages.
#	The COMMAND argument is evaluated in shell with the original
#	commit message on standard input; its standard output is
#	is used as the new commit message.
#
# --commit-filter COMMAND:: The filter for performing the commit
#	If this filter is passed, it will be called instead of the
#	`git commit-tree` command, with those arguments:
#
#		TREE_ID [-p PARENT_COMMIT_ID]...
#
#	and the log message on stdin. The commit id is expected on
#	stdout. As a special extension, the commit filter may emit
#	multiple commit ids; in that case, all of them will be used
#	as parents instead of the original commit in further commits.
#
# --tag-name-filter COMMAND:: The filter for rewriting tag names.
#	If this filter is passed, it will be called for every tag ref
#	that points to a rewritten object (or to a tag object which
#	points to a rewritten object). The original tag name is passed
#	via standard input, and the new tag name is expected on standard
#	output.
#
#	The original tags are not deleted, but can be overwritten;
#	use "--tag-name-filter=cat" to simply update the tags. In this
#	case, be very careful and make sure you have the old tags
#	backed up in case the conversion has run afoul.
#
#	Note that there is currently no support for proper rewriting of
#	tag objects; in layman terms, if the tag has a message or signature
#	attached, the rewritten tag won't have it. Sorry. (It is by
#	definition impossible to preserve signatures at any rate, though.)
#
# --subdirectory-filter DIRECTORY:: Only regard the history, as seen by
#	the given subdirectory. The result will contain that directory as
#	its project root.
#
# EXAMPLE USAGE
# -------------
# Suppose you want to remove a file (containing confidential information
# or copyright violation) from all commits:
#
#	git-filter-branch --tree-filter 'rm filename' newbranch
#
# A significantly faster version:
#
#	git-filter-branch --index-filter 'git update-index --remove filename' newbranch
#
# Now, you will get the rewritten history saved in the branch 'newbranch'
# (your current branch is left untouched).
#
# To "etch-graft" a commit to the revision history (set a commit to be
# the parent of the current initial commit and propagate that):
#
#	git-filter-branch --parent-filter sed\ 's/^$/-p graftcommitid/' newbranch
#
# (if the parent string is empty - therefore we are dealing with the
# initial commit - add graftcommit as a parent). Note that this assumes
# history with a single root (that is, no git-merge without common ancestors
# happened). If this is not the case, use:
#
#	git-filter-branch --parent-filter 'cat; [ "$GIT_COMMIT" = "COMMIT" ] && echo "-p GRAFTCOMMIT"' newbranch
#
# To remove commits authored by "Darl McBribe" from the history:
#
#	git-filter-branch --commit-filter 'if [ "$GIT_AUTHOR_NAME" = "Darl McBribe" ]; then shift; while [ -n "$1" ]; do shift; echo "$1"; shift; done; else git commit-tree "$@"; fi' newbranch
#
# (the shift magic first throws away the tree id and then the -p
# parameters). Note that this handles merges properly! In case Darl
# committed a merge between P1 and P2, it will be propagated properly
# and all children of the merge will become merge commits with P1,P2
# as their parents instead of the merge commit.
#
# To restrict rewriting to only part of the history, specify a revision
# range in addition to the new branch name. The new branch name will
# point to the top-most revision that a 'git rev-list' of this range
# will print.
#
# Consider this history:
#
#	     D--E--F--G--H
#	    /     /
#	A--B-----C
#
# To rewrite commits D,E,F,G,H, use:
#
#	git-filter-branch ... new-H C..H
#
# To rewrite commits E,F,G,H, use one of these:
#
#	git-filter-branch ... new-H C..H --not D
#	git-filter-branch ... new-H D..H --not C
#
# To move the whole tree into a subdirectory, or remove it from there:
#
# git-filter-branch --index-filter \
#	'git ls-files -s | sed "s-\t-&newsubdir/-" |
#		GIT_INDEX_FILE=$GIT_INDEX_FILE.new \
#			git update-index --index-info &&
#	 mv $GIT_INDEX_FILE.new $GIT_INDEX_FILE' directorymoved

# Testsuite: TODO

set -e

USAGE="git-filter-branch [-d TEMPDIR] [FILTERS] DESTBRANCH [REV-RANGE]"
. git-sh-setup

map()
{
	# if it was not rewritten, take the original
	test -r "$workdir/../map/$1" || echo "$1"
	cat "$workdir/../map/$1"
}

# When piped a commit, output a script to set the ident of either
# "author" or "committer

set_ident () {
	lid="$(echo "$1" | tr "A-Z" "a-z")"
	uid="$(echo "$1" | tr "a-z" "A-Z")"
	pick_id_script='
		/^'$lid' /{
			s/'\''/'\''\\'\'\''/g
			h
			s/^'$lid' \([^<]*\) <[^>]*> .*$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/export GIT_'$uid'_NAME='\''&'\''/p

			g
			s/^'$lid' [^<]* <\([^>]*\)> .*$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/export GIT_'$uid'_EMAIL='\''&'\''/p

			g
			s/^'$lid' [^<]* <[^>]*> \(.*\)$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/export GIT_'$uid'_DATE='\''&'\''/p

			q
		}
	'

	LANG=C LC_ALL=C sed -ne "$pick_id_script"
	# Ensure non-empty id name.
	echo "[ -n \"\$GIT_${uid}_NAME\" ] || export GIT_${uid}_NAME=\"\${GIT_${uid}_EMAIL%%@*}\""
}

tempdir=.git-rewrite
filter_env=
filter_tree=
filter_index=
filter_parent=
filter_msg=cat
filter_commit='git commit-tree "$@"'
filter_tag_name=
filter_subdir=
while case "$#" in 0) usage;; esac
do
	case "$1" in
	--)
		shift
		break
		;;
	-*)
		;;
	*)
		break;
	esac

	# all switches take one argument
	ARG="$1"
	case "$#" in 1) usage ;; esac
	shift
	OPTARG="$1"
	shift

	case "$ARG" in
	-d)
		tempdir="$OPTARG"
		;;
	--env-filter)
		filter_env="$OPTARG"
		;;
	--tree-filter)
		filter_tree="$OPTARG"
		;;
	--index-filter)
		filter_index="$OPTARG"
		;;
	--parent-filter)
		filter_parent="$OPTARG"
		;;
	--msg-filter)
		filter_msg="$OPTARG"
		;;
	--commit-filter)
		filter_commit="$OPTARG"
		;;
	--tag-name-filter)
		filter_tag_name="$OPTARG"
		;;
	--subdirectory-filter)
		filter_subdir="$OPTARG"
		;;
	*)
		usage
		;;
	esac
done

dstbranch="$1"
shift
test -n "$dstbranch" || die "missing branch name"
git show-ref "refs/heads/$dstbranch" 2> /dev/null &&
	die "branch $dstbranch already exists"

test ! -e "$tempdir" || die "$tempdir already exists, please remove it"
mkdir -p "$tempdir/t"
cd "$tempdir/t"
workdir="$(pwd)"

case "$GIT_DIR" in
/*)
	;;
*)
	GIT_DIR="$(pwd)/../../$GIT_DIR"
	;;
esac
export GIT_DIR GIT_WORK_TREE=.

export GIT_INDEX_FILE="$(pwd)/../index"
git read-tree # seed the index file

ret=0


mkdir ../map # map old->new commit ids for rewriting parents

case "$filter_subdir" in
"")
	git rev-list --reverse --topo-order --default HEAD \
		--parents "$@"
	;;
*)
	git rev-list --reverse --topo-order --default HEAD \
		--parents --full-history "$@" -- "$filter_subdir"
esac > ../revs
commits=$(cat ../revs | wc -l | tr -d " ")

test $commits -eq 0 && die "Found nothing to rewrite"

i=0
while read commit parents; do
	i=$(($i+1))
	printf "$commit ($i/$commits) "

	case "$filter_subdir" in
	"")
		git read-tree -i -m $commit
		;;
	*)
		git read-tree -i -m $commit:"$filter_subdir"
	esac

	export GIT_COMMIT=$commit
	git cat-file commit "$commit" >../commit

	eval "$(set_ident AUTHOR <../commit)"
	eval "$(set_ident COMMITTER <../commit)"
	eval "$filter_env" < /dev/null

	if [ "$filter_tree" ]; then
		git checkout-index -f -u -a
		# files that $commit removed are now still in the working tree;
		# remove them, else they would be added again
		git ls-files -z --others | xargs -0 rm -f
		eval "$filter_tree" < /dev/null
		git diff-index -r $commit | cut -f 2- | tr '\n' '\0' | \
			xargs -0 git update-index --add --replace --remove
		git ls-files -z --others | \
			xargs -0 git update-index --add --replace --remove
	fi

	eval "$filter_index" < /dev/null

	parentstr=
	for parent in $parents; do
		for reparent in $(map "$parent"); do
			parentstr="$parentstr -p $reparent"
		done
	done
	if [ "$filter_parent" ]; then
		parentstr="$(echo "$parentstr" | eval "$filter_parent")"
	fi

	sed -e '1,/^$/d' <../commit | \
		eval "$filter_msg" | \
		sh -c "$filter_commit" "git commit-tree" $(git write-tree) $parentstr | \
		tee ../map/$commit
done <../revs

src_head=$(tail -n 1 ../revs | sed -e 's/ .*//')
target_head=$(head -n 1 ../map/$src_head)
case "$target_head" in
'')
	echo Nothing rewritten
	;;
*)
	git update-ref refs/heads/"$dstbranch" $target_head
	if [ $(cat ../map/$src_head | wc -l) -gt 1 ]; then
		echo "WARNING: Your commit filter caused the head commit to expand to several rewritten commits. Only the first such commit was recorded as the current $dstbranch head but you will need to resolve the situation now (probably by manually merging the other commits). These are all the commits:" >&2
		sed 's/^/	/' ../map/$src_head >&2
		ret=1
	fi
	;;
esac

if [ "$filter_tag_name" ]; then
	git for-each-ref --format='%(objectname) %(objecttype) %(refname)' refs/tags |
	while read sha1 type ref; do
		ref="${ref#refs/tags/}"
		# XXX: Rewrite tagged trees as well?
		if [ "$type" != "commit" -a "$type" != "tag" ]; then
			continue;
		fi

		if [ "$type" = "tag" ]; then
			# Dereference to a commit
			sha1t="$sha1"
			sha1="$(git rev-parse "$sha1"^{commit} 2>/dev/null)" || continue
		fi

		[ -f "../map/$sha1" ] || continue
		new_sha1="$(cat "../map/$sha1")"
		export GIT_COMMIT="$sha1"
		new_ref="$(echo "$ref" | eval "$filter_tag_name")"

		echo "$ref -> $new_ref ($sha1 -> $new_sha1)"

		if [ "$type" = "tag" ]; then
			# Warn that we are not rewriting the tag object itself.
			warn "unreferencing tag object $sha1t"
		fi

		git update-ref "refs/tags/$new_ref" "$new_sha1"
	done
fi

cd ../..
rm -rf "$tempdir"
echo "Rewritten history saved to the $dstbranch branch"

exit $ret