Initial release of git-series
[gitseries.git] / git-series
blob77f5ac27a252fc9a36209e7b632f0f834d537768
1 #!/bin/bash
4 # Management of git commit series
6 # Copyright 2015 Jan H. Schönherr <jan@schnhrr.de>
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License version 2 as
10 # published by the Free Software Foundation.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 set -e -u
22 #set -x
24 B=$(basename "$0")
25 SUBDIRECTORY_OK=t
26 NONGIT_OK=
27 OPTIONS_STUCKLONG=t
28 OPTIONS_KEEPDASHDASH=
29 OPTIONS_SPEC="\
30 $0 [<options>]
32 Series Management:
33 $B add [--onto <base>] <start> [<series>]
34 $B remove [<series>]
35 $B create <series> [<start>]
36 $B delete <series>
38 Working with Series:
39 $B rebase [-i] [<series>]
40 $B show [<series>]
41 $B status
43 Experimental commands:
44 $B update-base <new-base> [<series>]
45 $B update-start <new-start> [<series>]
46 $B checkout <series-commit>
47 $B merged [<series>]
48 $B merge [<series>]
49 $B rev-parse <series-commit>
51 i,interactive! interactive rebase
52 onto!=base use a <base> different from <start>
54 . $(git --exec-path)/git-sh-setup
57 RED=
58 GREEN=
59 YELLOW=
60 NORMAL=
61 if [ -x /usr/bin/tput ]; then
62 RED=$(tput setaf 1)
63 GREEN=$(tput setaf 2)
64 YELLOW=$(tput setaf 3)
65 NORMAL=$(tput sgr0)
69 function start_reference() {
70 echo "refs/series/$1"
72 function rebase_reference() {
73 echo "refs/series-after-rebase/$1"
75 function base_reference() {
76 echo "series/$1/base"
78 function head_reference() {
79 echo "refs/heads/$1"
82 function ensure_rev() {
83 local rev
84 rev=$1
86 if ! git rev-parse --quiet --verify "$rev" >/dev/null; then
87 echo "$rev does not reference a valid revision." >&2
88 exit 1
92 function ensure_sym() {
93 local ref name
94 ref=$1
96 ensure_rev "$ref"
97 name=$(git rev-parse --symbolic-full-name "$ref")
98 if [[ -z $name || $name = HEAD ]]; then
99 echo "$ref is not a valid symbolic name." >&2
100 exit 1
104 function ensure_ancestor() {
105 local ancestor rev
106 ancestor=$1
107 rev=$2
109 if ! git merge-base --is-ancestor "$ancestor" "$rev"; then
110 echo "$ancestor is not an ancestor of $rev." >&2
111 exit 1
115 function series_exists() {
116 local series start_ref
117 series=$1
118 start_ref=$(start_reference "$series")
120 git show-ref --quiet --verify "$start_ref"
122 function ensure_series_exists() {
123 local series
125 series=$1
126 if ! series_exists "$series"; then
127 echo "Series $series does not exist." >&2
128 exit 1
132 function ensure_empty() {
133 if [[ -n $1 ]]; then
134 echo "Too many arguments." >&2
135 exit 1
138 function ensure_set() {
139 if [[ -z $1 ]]; then
140 echo "Missing arguments." >&2
141 exit 1
145 function normalize_series_name() {
146 # Regular branches in refs/heads/ are used as series.
147 # The name of the series does not have refs/heads/ in front. Thus,
148 # it has to be removed.
150 # A branch may be specified as master, heads/master, or
151 # refs/heads/master.
152 [[ $1 =~ ^((refs/)?heads/)?(.*) ]]
153 echo ${BASH_REMATCH[3]}
156 function current_branch() {
157 git rev-parse --symbolic-full-name HEAD
160 function get_commitcount() {
161 set -e
162 local series head start
163 series=$1
164 head=$(head_reference "$series")
165 start=$(start_reference "$series")
167 git rev-list --count "$start..$head" --
170 function calc_score() {
171 set -e
172 local series commit dist head start commit_sym
173 local start_is_ancestor head_is_descendant head_is_ancestor
174 series=$1
175 commit=$2
177 # highest class: directly on head of a series
178 commit_sym=$(git rev-parse --symbolic-full-name "$commit")
179 head=$(head_reference "$series")
180 if [[ $head = $commit_sym ]]; then
181 # shortest series wins
182 dist=$(get_commitcount "$series")
183 echo $((1000000 - dist))
184 exit 0
187 # second highest score: on some commit, which is part of the series
188 start=$(start_reference "$series")
189 start_is_ancestor=
190 head_is_descendant=
191 if git merge-base --is-ancestor "$start" "$commit"; then
192 start_is_ancestor=t
194 if git merge-base --is-ancestor "$commit" "$head"; then
195 head_is_descendant=t
197 if [[ -n $start_is_ancestor && -n $head_is_descendant ]]; then
198 # shortest series wins
199 dist=$(get_commitcount "$series")
200 echo $((900000 - dist))
201 exit 0
204 # third highest score: branched from some commit of the series
205 head_is_ancestor=
206 if git merge-base --is-ancestor "$head" "$commit"; then
207 head_is_ancestor=t
209 if [[ -n $start_is_ancestor && -z $head_is_ancestor ]]; then
210 # shortest distance to $start wins
211 dist=$(git rev-list --count "$start..$commit" --)
212 echo $((800000 - dist))
213 exit 0
216 # forth highest score: on top of a series
217 if [[ -n $head_is_ancestor ]]; then
218 # shortest distance to $head wins
219 dist=$(git rev-list --count "$head..$commit" --)
220 echo $((700000 - dist))
221 exit 0
224 # no match, no score
225 echo 0
226 exit 0
229 function all_series() {
230 git for-each-ref --format="%(refname)" "$(start_reference "*")" | sed -e 's#'"$(start_reference "\(.*\)")"'#\1#'
233 function current_series() {
234 set -e
236 local series s score maxscore
237 maxscore=0
238 series=
239 for s in $(all_series); do
240 score=$(calc_score "$s" HEAD)
241 if [[ $score -eq $maxscore ]]; then
242 series=
244 if [[ $score -gt $maxscore ]]; then
245 maxscore=$score
246 series=$s
248 done
250 if [[ -n $series ]]; then
251 echo $series
252 elif [[ $maxscore -gt 0 ]]; then
253 echo "Cannot determine series; try specifying one." >&2
254 return 1
255 else
256 echo "Currently not on any series; try specifying one." >&2
257 return 1
261 # usage: get_series <pos-of-series-arg-in-$@> "$@"
262 function get_series() {
263 set -e
264 local series
266 shift $1
267 if [[ $# -ge 1 ]]; then
268 series=$(normalize_series_name "$1")
269 else
270 series=$(current_series)
272 ensure_series_exists "$series"
273 echo "$series"
278 # Command functions
282 # usage: create <series> [<start>]
283 function create_series() {
284 local series head base
285 test $# -ge 1 || usage
286 test $# -le 2 || usage
287 series=$(normalize_series_name "$1")
288 head=$(head_reference "$series")
289 base=${2-HEAD}
291 if git show-ref --quiet --verify "$head"; then
292 echo "Series $series already exists."
293 exit
295 ensure_rev "$base"
296 git update-ref "$head" "$base" ""
297 add_series "$base" "$series"
300 # $B add [--onto <base>] <start> [<series>]
301 function add_series() {
302 local base series head start start_ref
303 test $# -ge 1 || usage
304 test $# -le 2 || usage
305 base=$1
306 if [[ $# -ge 2 ]]; then
307 series=$(normalize_series_name "$2")
308 else
309 series=$(normalize_series_name "$(current_branch)")
311 head=$(head_reference "$series")
312 ensure_rev "$head"
313 if [[ -n $onto ]]; then
314 base=$onto
315 start=$1
316 else
317 start=$(git merge-base "$head" "$base")
320 # sanity check parameters
321 ensure_sym "$base"
322 ensure_rev "$start"
323 ensure_ancestor "$start" "$head"
325 start_ref=$(start_reference "$series")
326 if git show-ref --quiet --verify "$start_ref"; then
327 echo "Series $series already exists."
328 exit
331 # create static reference to start, so that we see when
332 # base and start diverge
333 git update-ref "$start_ref" "$start" ""
335 # create symbolic reference to base, so that it moves with the target
336 local base_ref base_sym
337 base_ref=$(base_reference "$series")
338 base_sym=$(git rev-parse --symbolic-full-name "$base")
339 git symbolic-ref "$base_ref" "$base_sym"
341 show_series "$series"
344 # usage: remove [<series>]
345 function remove_series() {
346 local series
347 test $# -le 1 || usage
348 series=$(get_series 1 "$@")
350 git update-ref -d "$(start_reference "$series")"
351 git update-ref -d "$(rebase_reference "$series")"
352 git symbolic-ref --delete "$(base_reference "$series")"
355 # usage: delete <series>
356 function delete_series() {
357 local series
358 test $# -eq 1 || usage
359 series=$(normalize_series_name "$1")
361 ensure_series_exists "$series"
363 #git update-ref -d "$(head_reference "$series")"
364 git branch -D "$series"
365 remove_series "$series"
368 # usage: show [<series>]
369 function show_series() {
370 local series start base_sym base
371 test $# -le 1 || usage
372 series=$(get_series 1 "$@")
374 start=$(git show-ref --verify --hash "$(start_reference "$series")")
376 echo "Commits in series $series:"
378 git --no-pager log --oneline "$start..$(head_reference "$series")" --
380 base_sym=$(git symbolic-ref "$(base_reference "$series")")
381 base=$(git show-ref --verify --hash "$base_sym")
382 if [[ $base = $start ]]; then
383 echo "${GREEN}Your series is still on top of its base $base_sym.${NORMAL}"
384 else
385 echo "${RED}The base $base_sym of your series has changed!${NORMAL}"
386 echo "Changes in $base_sym since last rebase:"
387 git --no-pager log --oneline --left-right "$start...$base" --
391 # usage: rebase [-i] [<series>]
392 function rebase_series() {
393 local series head start_ref start base_sym rebase_ref
394 test $# -le 1 || usage
395 series=$(get_series 1 "$@")
397 head=$(head_reference "$series")
398 start_ref=$(start_reference "$series")
399 start=$(git show-ref --verify --hash "$start_ref")
400 base_sym=$(git symbolic-ref "$(base_reference "$series")")
401 rebase_ref=$(rebase_reference "$series")
403 if [[ -n $interactive ]]; then
404 git rebase -i "$start" "$series"
405 exit
408 if git show-ref --quiet --verify "$rebase_ref"; then
409 if git merge-base --is-ancestor "$base_sym" "$head"; then
410 echo "It seems, you finished the last rebase successfully. Cleaning up."
411 git update-ref -d "$rebase_ref"
412 git update-ref "$start_ref" "$base_sym" "$start"
413 return
415 if [[ -d "$GIT_DIR/rebase-merge" || -d "$GIT_DIR/rebase-apply" ]]; then
416 echo "A rebase for series $series was not finished."
417 echo "It seems to be still in progress. Please rerun"
418 echo " $0 rebase $series"
419 echo "after you have either finished or aborted the rebase to sort"
420 echo "out the state of the series."
421 exit
423 echo "It seems, you aborted it. Cleaning up."
424 git update-ref -d "$rebase_ref"
425 if ! git merge-base --is-ancestor "$start" "$head"; then
426 echo "(Though the state looks weird.)"
428 return
431 git update-ref "$rebase_ref" "$base_sym" ""
432 echo "Starting rebase of series $series onto $base_sym."
434 if git rebase --onto "$base_sym" "$start" "$series"; then
435 if git merge-base --is-ancestor "$base_sym" "$head"; then
436 echo "Rebase finished successfully."
437 git update-ref -d "$rebase_ref"
438 git update-ref "$start_ref" "$base_sym" "$start"
439 else
440 echo "Rebase still in progress?! Please rerun"
441 echo " $0 rebase $series"
442 echo "after you have either finished or aborted the rebase to sort"
443 echo "out the state of the series."
445 else
446 echo "Rebase still in progress. Please rerun"
447 echo " $0 rebase $series"
448 echo "after you have either finished or aborted the rebase to sort"
449 echo "out the state of the series."
453 # usage: checkout <series-commit>
454 function checkout() {
455 local commit
456 test $# -eq 1 || usage
457 commit=$(rev_parse "$1")
459 git checkout "$commit" --
462 function get_status() {
463 local series start base_sym base
464 series=$1
465 start=$(git show-ref --verify --hash "$(start_reference "$series")")
466 base_sym=$(git symbolic-ref "$(base_reference "$series")")
467 base=$(git show-ref --verify --hash "$base_sym")
469 if ! git show-ref --verify --quiet "$(head_reference "$series")"; then
470 echo "${RED}Missing head!${NORMAL} Did you delete the branch?"
471 elif git show-ref --verify --quiet "$(rebase_reference "$series")"; then
472 echo "${RED}Rebase in progress!${NORMAL} Please finish it."
473 elif [[ $base = $start ]]; then
474 echo "${GREEN}Still on top of $base_sym.${NORMAL}"
475 else
476 echo "${YELLOW}Needs rebase onto $base_sym!${NORMAL}"
480 # usage: status
481 function status() {
482 local series_list series pad current
483 test $# -eq 0 || usage
485 series_list=$(all_series)
487 # determine length of longest series
488 pad=0
489 for series in $series_list; do
490 if [[ ${#series} -gt $pad ]]; then
491 pad=${#series}
493 done
495 current=$(current_series 2>/dev/null ||:)
496 for series in $series_list; do
497 #show_series "$series"
498 local status is_current commits
499 status=$(get_status "$series")
501 is_current=" "
502 if [[ $current = $series ]]; then
503 is_current="*"
506 commits=$(get_commitcount "$series" 2>/dev/null || echo "??")
507 if [[ $commits = 1 ]]; then
508 commits="$commits commit. "
509 else
510 commits="$commits commits."
513 printf '%c %-*s' "$is_current" "$pad" "$series"
514 printf ' %12s ' "$commits"
515 printf '%s\n' "$status"
516 done
519 # usage: update-base <new-base> [<series>]
520 function update_base() {
521 local newbase series
522 test $# -ge 1 || usage
523 test $# -le 2 || usage
524 newbase=$1
525 series=$(get_series 2 "$@")
527 # Sanity checks
528 ensure_sym "$newbase"
530 # Update the symbolic base reference with the provided value
531 local base_ref base_sym
532 base_ref=$(base_reference "$series")
533 base_sym=$(git rev-parse --symbolic-full-name "$newbase")
534 git symbolic-ref "$base_ref" "$base_sym"
536 show_series "$series"
539 # usage: update-start <new-start> [<series>]
540 function update_start() {
541 local newstart series
542 test $# -ge 1 || usage
543 test $# -le 2 || usage
544 newstart=$1
545 series=$(get_series 2 "$@")
547 # Sanity checks
548 local head
549 head=$(head_reference "$series")
550 ensure_rev "$newstart"
551 ensure_ancestor "$newstart" "$head"
553 # Update the start reference with the provided value
554 local start_ref start
555 start_ref=$(start_reference "$series")
556 start=$(git show-ref --verify --hash "$start_ref")
557 git update-ref "$start_ref" "$newstart" "$start"
559 show_series "$series"
562 # usage: merged [<series>]
563 function merged_series() {
564 local series head start_ref start base_sym merge_base
565 test $# -le 1 || usage
566 series=$(get_series 1 "$@")
568 head=$(head_reference "$series")
569 start_ref=$(start_reference "$series")
570 start=$(git show-ref --verify --hash "$start_ref")
571 base_sym=$(git symbolic-ref "$(base_reference "$series")")
573 merge_base=$(git merge-base "$head" "$base_sym")
574 git update-ref "$start_ref" "$merge_base" "$start"
577 # usage: merge [<series>]
578 function merge_series() {
579 local series start_ref
581 test $# -le 1 || usage
582 series=$(get_series 1 "$@")
584 # make sure HEAD forked from series
585 start_ref=$(start_reference "$series")
586 ensure_ancestor "$start_ref" HEAD
588 echo "Integrating HEAD into $series"
589 git rebase HEAD "$series"
592 # usage: rev-parse <series-commit>
593 function rev_parse() {
594 set -e
595 local commit series addendum count
596 test $# -eq 1 || usage
597 commit=$1
599 [[ $commit =~ ^([^^~+:]*)(.*) ]]
600 series=${BASH_REMATCH[1]}
601 addendum=${BASH_REMATCH[2]}
603 if [[ -z $series ]]; then
604 series=$(current_series)
606 ensure_series_exists "$series"
608 if [[ -n $addendum ]]; then
609 if [[ $addendum =~ ^([0-9~^]*)$ ]]; then
610 # normal revision
611 commit="$series${BASH_REMATCH[1]}"
612 elif [[ $addendum =~ ^\+([0-9]+)$ ]]; then
613 # <series>+<n>: n-th commit in series
614 count=$(get_commitcount "$series")
615 count=$((count-${BASH_REMATCH[1]}))
616 commit="$series~$count"
617 elif [[ $addendum =~ ^:([0-9]+)$ ]]; then
618 # <series>:<n>: n-th patch in series
619 # (don't count fixups/squashs)
620 local n title
621 count=$(get_commitcount "$series")
622 n=${BASH_REMATCH[1]}
623 while [[ $n -gt 0 ]]; do
624 count=$((count-1))
625 commit="$series~$count"
626 title=$(git log --format=format:%s "$commit" --)
627 if [[ ! $title =~ ^(squash|fixup)!\ ]]; then
628 n=$((n-1))
630 done
631 else
632 echo "Cannot parse series-commit" >&2
633 exit 1
636 echo "$commit"
639 interactive=
640 onto=
641 while [[ $# -gt 0 ]]; do case $1 in
642 --interactive) interactive=t;;
643 --onto=*) onto=${1#*=};;
644 --) shift; break;;
645 *) usage;;
646 esac; shift; done
648 if [[ $# -eq 0 ]]; then
649 cmd="status"
650 else
651 cmd=$1
652 shift
655 case $cmd in
656 # Management
657 create) create_series "$@";;
658 add) add_series "$@";;
659 remove) remove_series "$@";;
660 delete) delete_series "$@";;
662 # Working
663 rebase) rebase_series "$@";;
664 show) show_series "$@";;
665 status) status "$@";;
667 # Experimental commands
668 update-base) update_base "$@";;
669 update-start) update_start "$@";;
670 checkout) checkout "$@";;
671 merged) merged_series "$@";;
672 merge) merge_series "$@";;
673 rev-parse) rev_parse "$@";;
675 *) usage;;
676 esac