52d4c0aeb135d0e760516e38640d11229c66ca42
1#!/bin/bash
2#
3# git-subtree.sh: split/join git repositories in subdirectories of this one
4#
5# Copyright (C) 2009 Avery Pennarun <apenwarr@gmail.com>
6#
7if [ $# -eq 0 ]; then
8 set -- -h
9fi
10OPTS_SPEC="\
11git subtree add --prefix=<prefix> <commit>
12git subtree merge --prefix=<prefix> <commit>
13git subtree pull --prefix=<prefix> <repository> <refspec...>
14git subtree split --prefix=<prefix> <commit...>
15--
16h,help show the help
17q quiet
18d show debug messages
19P,prefix= the name of the subdir to split out
20m,message= use the given message as the commit message for the merge commit
21 options for 'split'
22annotate= add a prefix to commit message of new commits
23b,branch= create a new branch from the split subtree
24ignore-joins ignore prior --rejoin commits
25onto= try connecting new tree to an existing one
26rejoin merge the new branch back into HEAD
27 options for 'add', 'merge', and 'pull'
28squash merge subtree changes as a single commit
29"
30eval $(echo "$OPTS_SPEC" | git rev-parse --parseopt -- "$@" || echo exit $?)
31PATH=$(git --exec-path):$PATH
32. git-sh-setup
33require_work_tree
34
35quiet=
36branch=
37debug=
38command=
39onto=
40rejoin=
41ignore_joins=
42annotate=
43squash=
44message=
45
46debug()
47{
48 if [ -n "$debug" ]; then
49 echo "$@" >&2
50 fi
51}
52
53say()
54{
55 if [ -z "$quiet" ]; then
56 echo "$@" >&2
57 fi
58}
59
60assert()
61{
62 if "$@"; then
63 :
64 else
65 die "assertion failed: " "$@"
66 fi
67}
68
69
70#echo "Options: $*"
71
72while [ $# -gt 0 ]; do
73 opt="$1"
74 shift
75 case "$opt" in
76 -q) quiet=1 ;;
77 -d) debug=1 ;;
78 --annotate) annotate="$1"; shift ;;
79 --no-annotate) annotate= ;;
80 -b) branch="$1"; shift ;;
81 -P) prefix="$1"; shift ;;
82 -m) message="$1"; shift ;;
83 --no-prefix) prefix= ;;
84 --onto) onto="$1"; shift ;;
85 --no-onto) onto= ;;
86 --rejoin) rejoin=1 ;;
87 --no-rejoin) rejoin= ;;
88 --ignore-joins) ignore_joins=1 ;;
89 --no-ignore-joins) ignore_joins= ;;
90 --squash) squash=1 ;;
91 --no-squash) squash= ;;
92 --) break ;;
93 *) die "Unexpected option: $opt" ;;
94 esac
95done
96
97command="$1"
98shift
99case "$command" in
100 add|merge|pull) default= ;;
101 split) default="--default HEAD" ;;
102 *) die "Unknown command '$command'" ;;
103esac
104
105if [ -z "$prefix" ]; then
106 die "You must provide the --prefix option."
107fi
108
109if [ "$command" = "split" -a """"! -e "$prefix" ]; then
110 die "$prefix does not exist."
111fi
112
113dir="$(dirname "$prefix/.")"
114
115if [ "$command" != "pull" ]; then
116 revs=$(git rev-parse $default --revs-only "$@") || exit $?
117 dirs="$(git rev-parse --no-revs --no-flags "$@")" || exit $?
118 if [ -n "$dirs" ]; then
119 die "Error: Use --prefix instead of bare filenames."
120 fi
121fi
122
123debug "command: {$command}"
124debug "quiet: {$quiet}"
125debug "revs: {$revs}"
126debug "dir: {$dir}"
127debug "opts: {$*}"
128debug
129
130cache_setup()
131{
132 cachedir="$GIT_DIR/subtree-cache/$$"
133 rm -rf "$cachedir" || die "Can't delete old cachedir: $cachedir"
134 mkdir -p "$cachedir" || die "Can't create new cachedir: $cachedir"
135 debug "Using cachedir: $cachedir" >&2
136}
137
138cache_get()
139{
140 for oldrev in $*; do
141 if [ -r "$cachedir/$oldrev" ]; then
142 read newrev <"$cachedir/$oldrev"
143 echo $newrev
144 fi
145 done
146}
147
148cache_set()
149{
150 oldrev="$1"
151 newrev="$2"
152 if [ "$oldrev" != "latest_old" \
153 -a "$oldrev" != "latest_new" \
154 -a -e "$cachedir/$oldrev" ]; then
155 die "cache for $oldrev already exists!"
156 fi
157 echo "$newrev" >"$cachedir/$oldrev"
158}
159
160rev_exists()
161{
162 if git rev-parse "$1" >/dev/null 2>&1; then
163 return 0
164 else
165 return 1
166 fi
167}
168
169rev_is_descendant_of_branch()
170{
171 newrev="$1"
172 branch="$2"
173 branch_hash=$(git rev-parse $branch)
174 match=$(git rev-list -1 $branch_hash ^$newrev)
175
176 if [ -z "$match" ]; then
177 return 0
178 else
179 return 1
180 fi
181}
182
183# if a commit doesn't have a parent, this might not work. But we only want
184# to remove the parent from the rev-list, and since it doesn't exist, it won't
185# be there anyway, so do nothing in that case.
186try_remove_previous()
187{
188 if rev_exists "$1^"; then
189 echo "^$1^"
190 fi
191}
192
193find_latest_squash()
194{
195 debug "Looking for latest squash ($dir)..."
196 dir="$1"
197 sq=
198 main=
199 sub=
200 git log --grep="^git-subtree-dir: $dir/*\$" \
201 --pretty=format:'START %H%n%s%n%n%b%nEND%n' HEAD |
202 while read a b junk; do
203 debug "$a $b $junk"
204 debug "{{$sq/$main/$sub}}"
205 case "$a" in
206 START) sq="$b" ;;
207 git-subtree-mainline:) main="$b" ;;
208 git-subtree-split:) sub="$b" ;;
209 END)
210 if [ -n "$sub" ]; then
211 if [ -n "$main" ]; then
212 # a rejoin commit?
213 # Pretend its sub was a squash.
214 sq="$sub"
215 fi
216 debug "Squash found: $sq $sub"
217 echo "$sq" "$sub"
218 break
219 fi
220 sq=
221 main=
222 sub=
223 ;;
224 esac
225 done
226}
227
228find_existing_splits()
229{
230 debug "Looking for prior splits..."
231 dir="$1"
232 revs="$2"
233 main=
234 sub=
235 git log --grep="^git-subtree-dir: $dir/*\$" \
236 --pretty=format:'START %H%n%s%n%n%b%nEND%n' $revs |
237 while read a b junk; do
238 case "$a" in
239 START) sq="$b" ;;
240 git-subtree-mainline:) main="$b" ;;
241 git-subtree-split:) sub="$b" ;;
242 END)
243 debug " Main is: '$main'"
244 if [ -z "$main" -a -n "$sub" ]; then
245 # squash commits refer to a subtree
246 debug " Squash: $sq from $sub"
247 cache_set "$sq" "$sub"
248 fi
249 if [ -n "$main" -a -n "$sub" ]; then
250 debug " Prior: $main -> $sub"
251 cache_set $main $sub
252 try_remove_previous "$main"
253 try_remove_previous "$sub"
254 fi
255 main=
256 sub=
257 ;;
258 esac
259 done
260}
261
262copy_commit()
263{
264 # We're going to set some environment vars here, so
265 # do it in a subshell to get rid of them safely later
266 debug copy_commit "{$1}" "{$2}" "{$3}"
267 git log -1 --pretty=format:'%an%n%ae%n%ad%n%cn%n%ce%n%cd%n%s%n%n%b' "$1" |
268 (
269 read GIT_AUTHOR_NAME
270 read GIT_AUTHOR_EMAIL
271 read GIT_AUTHOR_DATE
272 read GIT_COMMITTER_NAME
273 read GIT_COMMITTER_EMAIL
274 read GIT_COMMITTER_DATE
275 export GIT_AUTHOR_NAME \
276 GIT_AUTHOR_EMAIL \
277 GIT_AUTHOR_DATE \
278 GIT_COMMITTER_NAME \
279 GIT_COMMITTER_EMAIL \
280 GIT_COMMITTER_DATE
281 (echo -n "$annotate"; cat ) |
282 git commit-tree "$2" $3 # reads the rest of stdin
283 ) || die "Can't copy commit $1"
284}
285
286add_msg()
287{
288 dir="$1"
289 latest_old="$2"
290 latest_new="$3"
291 if [ -n "$message" ]; then
292 commit_message="$message"
293 else
294 commit_message="Add '$dir/' from commit '$latest_new'"
295 fi
296 cat <<-EOF
297 $commit_message
298
299 git-subtree-dir: $dir
300 git-subtree-mainline: $latest_old
301 git-subtree-split: $latest_new
302 EOF
303}
304
305add_squashed_msg()
306{
307 if [ -n "$message" ]; then
308 echo "$message"
309 else
310 echo "Merge commit '$1' as '$2'"
311 fi
312}
313
314rejoin_msg()
315{
316 dir="$1"
317 latest_old="$2"
318 latest_new="$3"
319 if [ -n "$message" ]; then
320 commit_message="$message"
321 else
322 commit_message="Split '$dir/' into commit '$latest_new'"
323 fi
324 cat <<-EOF
325 $commit_message
326
327 git-subtree-dir: $dir
328 git-subtree-mainline: $latest_old
329 git-subtree-split: $latest_new
330 EOF
331}
332
333squash_msg()
334{
335 dir="$1"
336 oldsub="$2"
337 newsub="$3"
338 newsub_short=$(git rev-parse --short "$newsub")
339
340 if [ -n "$oldsub" ]; then
341 oldsub_short=$(git rev-parse --short "$oldsub")
342 echo "Squashed '$dir/' changes from $oldsub_short..$newsub_short"
343 echo
344 git log --pretty=tformat:'%h %s' "$oldsub..$newsub"
345 git log --pretty=tformat:'REVERT: %h %s' "$newsub..$oldsub"
346 else
347 echo "Squashed '$dir/' content from commit $newsub_short"
348 fi
349
350 echo
351 echo "git-subtree-dir: $dir"
352 echo "git-subtree-split: $newsub"
353}
354
355toptree_for_commit()
356{
357 commit="$1"
358 git log -1 --pretty=format:'%T' "$commit" -- || exit $?
359}
360
361subtree_for_commit()
362{
363 commit="$1"
364 dir="$2"
365 git ls-tree "$commit" -- "$dir" |
366 while read mode type tree name; do
367 assert [ "$name" = "$dir" ]
368 assert [ "$type" = "tree" ]
369 echo $tree
370 break
371 done
372}
373
374tree_changed()
375{
376 tree=$1
377 shift
378 if [ $# -ne 1 ]; then
379 return 0 # weird parents, consider it changed
380 else
381 ptree=$(toptree_for_commit $1)
382 if [ "$ptree" != "$tree" ]; then
383 return 0 # changed
384 else
385 return 1 # not changed
386 fi
387 fi
388}
389
390new_squash_commit()
391{
392 old="$1"
393 oldsub="$2"
394 newsub="$3"
395 tree=$(toptree_for_commit $newsub) || exit $?
396 if [ -n "$old" ]; then
397 squash_msg "$dir" "$oldsub" "$newsub" |
398 git commit-tree "$tree" -p "$old" || exit $?
399 else
400 squash_msg "$dir" "" "$newsub" |
401 git commit-tree "$tree" || exit $?
402 fi
403}
404
405copy_or_skip()
406{
407 rev="$1"
408 tree="$2"
409 newparents="$3"
410 assert [ -n "$tree" ]
411
412 identical=
413 nonidentical=
414 p=
415 gotparents=
416 for parent in $newparents; do
417 ptree=$(toptree_for_commit $parent) || exit $?
418 [ -z "$ptree" ] && continue
419 if [ "$ptree" = "$tree" ]; then
420 # an identical parent could be used in place of this rev.
421 identical="$parent"
422 else
423 nonidentical="$parent"
424 fi
425
426 # sometimes both old parents map to the same newparent;
427 # eliminate duplicates
428 is_new=1
429 for gp in $gotparents; do
430 if [ "$gp" = "$parent" ]; then
431 is_new=
432 break
433 fi
434 done
435 if [ -n "$is_new" ]; then
436 gotparents="$gotparents $parent"
437 p="$p -p $parent"
438 fi
439 done
440
441 if [ -n "$identical" ]; then
442 echo $identical
443 else
444 copy_commit $rev $tree "$p" || exit $?
445 fi
446}
447
448ensure_clean()
449{
450 if ! git diff-index HEAD --exit-code --quiet; then
451 die "Working tree has modifications. Cannot add."
452 fi
453 if ! git diff-index --cached HEAD --exit-code --quiet; then
454 die "Index has modifications. Cannot add."
455 fi
456}
457
458cmd_add()
459{
460 if [ -e "$dir" ]; then
461 die "'$dir' already exists. Cannot add."
462 fi
463 ensure_clean
464
465 set -- $revs
466 if [ $# -ne 1 ]; then
467 die "You must provide exactly one revision. Got: '$revs'"
468 fi
469 rev="$1"
470
471 debug "Adding $dir as '$rev'..."
472 git read-tree --prefix="$dir" $rev || exit $?
473 git checkout -- "$dir" || exit $?
474 tree=$(git write-tree) || exit $?
475
476 headrev=$(git rev-parse HEAD) || exit $?
477 if [ -n "$headrev" -a "$headrev" != "$rev" ]; then
478 headp="-p $headrev"
479 else
480 headp=
481 fi
482
483 if [ -n "$squash" ]; then
484 rev=$(new_squash_commit "" "" "$rev") || exit $?
485 commit=$(add_squashed_msg "$rev" "$dir" |
486 git commit-tree $tree $headp -p "$rev") || exit $?
487 else
488 commit=$(add_msg "$dir" "$headrev" "$rev" |
489 git commit-tree $tree $headp -p "$rev") || exit $?
490 fi
491 git reset "$commit" || exit $?
492
493 say "Added dir '$dir'"
494}
495
496cmd_split()
497{
498 debug "Splitting $dir..."
499 cache_setup || exit $?
500
501 if [ -n "$onto" ]; then
502 debug "Reading history for --onto=$onto..."
503 git rev-list $onto |
504 while read rev; do
505 # the 'onto' history is already just the subdir, so
506 # any parent we find there can be used verbatim
507 debug " cache: $rev"
508 cache_set $rev $rev
509 done
510 fi
511
512 if [ -n "$ignore_joins" ]; then
513 unrevs=
514 else
515 unrevs="$(find_existing_splits "$dir" "$revs")"
516 fi
517
518 # We can't restrict rev-list to only $dir here, because some of our
519 # parents have the $dir contents the root, and those won't match.
520 # (and rev-list --follow doesn't seem to solve this)
521 grl='git rev-list --reverse --parents $revs $unrevs'
522 revmax=$(eval "$grl" | wc -l)
523 revcount=0
524 createcount=0
525 eval "$grl" |
526 while read rev parents; do
527 revcount=$(($revcount + 1))
528 say -n "$revcount/$revmax ($createcount)
"
529 debug "Processing commit: $rev"
530 exists=$(cache_get $rev)
531 if [ -n "$exists" ]; then
532 debug " prior: $exists"
533 continue
534 fi
535 createcount=$(($createcount + 1))
536 debug " parents: $parents"
537 newparents=$(cache_get $parents)
538 debug " newparents: $newparents"
539
540 tree=$(subtree_for_commit $rev "$dir")
541 debug " tree is: $tree"
542
543 # ugly. is there no better way to tell if this is a subtree
544 # vs. a mainline commit? Does it matter?
545 if [ -z $tree ]; then
546 cache_set $rev $rev
547 continue
548 fi
549
550 newrev=$(copy_or_skip "$rev" "$tree" "$newparents") || exit $?
551 debug " newrev is: $newrev"
552 cache_set $rev $newrev
553 cache_set latest_new $newrev
554 cache_set latest_old $rev
555 done || exit $?
556 latest_new=$(cache_get latest_new)
557 if [ -z "$latest_new" ]; then
558 die "No new revisions were found"
559 fi
560
561 if [ -n "$rejoin" ]; then
562 debug "Merging split branch into HEAD..."
563 latest_old=$(cache_get latest_old)
564 git merge -s ours \
565 -m "$(rejoin_msg $dir $latest_old $latest_new)" \
566 $latest_new >&2 || exit $?
567 fi
568 if [ -n "$branch" ]; then
569 if rev_exists "refs/heads/$branch"; then
570 if ! rev_is_descendant_of_branch $latest_new $branch; then
571 die "Branch '$branch' is not an ancestor of commit '$latest_new'."
572 fi
573 action='Updated'
574 else
575 action='Created'
576 fi
577 git update-ref -m 'subtree split' "refs/heads/$branch" $latest_new || exit $?
578 say "$action branch '$branch'"
579 fi
580 echo $latest_new
581 exit 0
582}
583
584cmd_merge()
585{
586 ensure_clean
587
588 set -- $revs
589 if [ $# -ne 1 ]; then
590 die "You must provide exactly one revision. Got: '$revs'"
591 fi
592 rev="$1"
593
594 if [ -n "$squash" ]; then
595 first_split="$(find_latest_squash "$dir")"
596 if [ -z "$first_split" ]; then
597 die "Can't squash-merge: '$dir' was never added."
598 fi
599 set $first_split
600 old=$1
601 sub=$2
602 if [ "$sub" = "$rev" ]; then
603 say "Subtree is already at commit $rev."
604 exit 0
605 fi
606 new=$(new_squash_commit "$old" "$sub" "$rev") || exit $?
607 debug "New squash commit: $new"
608 rev="$new"
609 fi
610
611 if [ -n "$message" ]; then
612 git merge -s subtree --message="$message" $rev
613 else
614 git merge -s subtree $rev
615 fi
616}
617
618cmd_pull()
619{
620 ensure_clean
621 git fetch "$@" || exit $?
622 revs=FETCH_HEAD
623 cmd_merge
624}
625
626"cmd_$command" "$@"