#!/usr/bin/env bash
# Copyright (c) 2012, Code Aurora Forum. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#    # Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#    # Redistributions in binary form must reproduce the above
#       copyright notice, this list of conditions and the following
#       disclaimer in the documentation and/or other materials provided
#       with the distribution.
#    # Neither the name of Code Aurora Forum, Inc. nor the names of its
#       contributors may be used to endorse or promote products derived
#       from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT
# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

usage() { # error_message

    cat <<-EOF
		usage: $(basename $0) [-unvt] [--noref] [--nolosse] [-r|--ratio number]
		                      [git gc option...] git.repo

		-u|-h                usage/help
		-v verbose
		-n dry-run           don't actually repack anything
		-t touch             treat repo as if it had been touched
		--noref              avoid extra ref packing timestamp checking
		--noloose            do not run just because there are loose object dirs
		                     (repacking may still run if they are referenced)
		-r ratio <number>    packfile ratio to aim for (default 10)

		git gc option        will be passed as args to git gc

		git.repo             to run gc against

		Garbage collect using a pseudo logarithmic packfile maintenance
		approach.  This approach attempts to minimize packfile churn
		by keeping several generations of varying sized packfiles around
		and only consolidating packfiles (or loose objects) which are
		either new packfiles, or packfiles close to the same size as
		another packfile.

		An estimate is used to predict when rollups (one consolidation
		would cause another consolidation) would occur so that this
		rollup can be done all at once via a single repack.  This reduces
		both the runtime and the pack file churn in rollup cases.

		Approach: plan each consolidation by creating a table like this:

		Id Keep Size           Sha1(or consolidation list)      Actions(repack down up note)
		1     - 11356          9052edfb7392646cd4e5f362b953675985f01f96 y - - New
		2     - 429088         010904d5c11cd26a79fda91b01ab454d1001b402 y - - New
		c1    - 440444         [1,2]                                    - - -

		Id:    numbers preceded by a c are estimated "c pack" files
		Keep:  - none, k private keep, o our keep
		Size:  in disk blocks (default du output)
		Sha1:  of packfile, or consolidation list of packfile ids
		Actions
		repack: - n no, y yes
		down:   - noop, ^ consolidate with a file above
		up:     - noop, v consolidate with a file below
		note:   Human description of script decisions:
		         New (file is a new packfile)
		         Consolidate with:<list of packfile ids>
		         (too far from:<list of packfile ids>)

		On the first pass, always consolidate any new packfiles along
		with loose objects and along with any packfiles which are within
		the ratio size of their predecessors (note, the list is ordered
		by increasing size).  After each consolidation, insert a fake
		consolidation, or "c pack", to naively represent the size and
		ordered positioning of the anticipated new consolidated pack.
		Every time a new pack is planned, rescan the list in case the
		new "c pack" would cause more consolidation...

		Once the packfiles which need consolidation are determined, the
		packfiles which will not be consolidated are marked with a .keep
		file, and those which will be consolidated will have their .keep
		removed if they have one.  Thus, the packfiles with a .keep will
		not get repacked.

		Packfile consolidation is determined by the --ratio parameter
		(default is 10).  This ratio is somewhat of a tradeoff.  The
		smaller the number, the more packfiles will be kept on average;
		this increases disk utilization somewhat.  However, a larger
		ratio causes greater churn and may increase disk utilization due
		to deleted packfiles not being reclaimed since they may still be
		kept open by long running applications such as Gerrit.  Sane
		ratio values are probably between 2 and 10.  Since most
		consolidations actually end up smaller than the estimated
		consolidated packfile size (due to compression), the true ratio
		achieved will likely be 1 to 2 greater than the target ratio.
		The smaller the target ratio, the greater this discrepancy.

		Finally, attempt to skip garbage collection entirely on untouched
		repos.  In order to determine if a repo has been touched, use the
		timestamp on the script's keep files, if any relevant file/dir
		is newer than a keep marker file, assume that the repo has been
		touched and gc needs to run.  Also assume gc needs to run whenever
		there are loose object dirs since they may contain untouched
		unreferenced loose objects which need to be pruned (once they
		expire).

		In order to allow the keep files to be an effective timestamp
		marker to detect relevant changes in a repo since the last run,
		all relevant files and directories which may be modified during a
		gc run (even during a noop gc run), must have their timestamps
		reset to the same time as the keep files or gc will always run
		even on untouched repos.  The relevant files/dirs are all those
		files and directories which garbage collection, object packing,
		ref packing and pruning might change during noop actions.
EOF

    [ -n "$1" ] && info "ERROR $1"

    exit 128
}

debug() { [ -n "$SW_V" ] && info "$1" ; }
info() { echo "$1" >&2 ; }

array_copy() { #v2 # array_src array_dst
    local src=$1 dst=$2
    local s i=0
    eval s=\${#$src[@]}
    while [ $i -lt $s ] ; do
        eval $dst[$i]=\"\${$src[$i]}\"
        i=$(($i + 1))
    done
}

array_equals() { #v2 # array_name [vals...]
    local a=$1 ; shift
    local s=0 t=() val
    array_copy "$a" t
    for s in "${!t[@]}" ; do s=$((s+1)) ; done
    [ "$s" -ne "$#" ] && return 1
    for val in "${t[@]}" ; do
        [ "$val" = "$1" ] || return 2
        shift
    done
    return 0
}

packs_sizes() { # git.repo > "size pack"...
    du -s "$1"/objects/pack/pack-$SHA1.pack | sort -n 2> /dev/null
}

is_ourkeep() { grep -q "$KEEP" "$1" 2> /dev/null ; } # keep
has_ourkeep() { is_ourkeep "$(keep_for "$1")" ; } # pack
has_keep() { [ -f "$(keep_for "$1")" ] ; } # pack
is_repo() { [ -d "$1/objects" ] && [ -d "$1/refs/heads" ] ; } # git.repo

keep() { # pack   # returns true if we added our keep
    keep=$(keep_for "$1")
    [ -f "$keep" ] && return 1
    echo "$KEEP" > "$keep"
    return 0
}

keep_for() { # packfile > keepfile
    local keep=$(echo "$1" | sed -es'/\.pack$/.keep/')
    [ "${keep/.keep}" = "$keep" ] && return 1
    echo "$keep"
}

idx_for() { # packfile > idxfile
    local idx=$(echo "$1" | sed -es'/\.pack$/.idx/')
    [ "${idx/.idx}" = "$idx" ] && return 1
    echo "$idx"
}

# pack_or_keep_file > sha
sha_for() { echo "$1" | sed -es'|\(.*/\)*pack-\([^.]*\)\..*$|\2|' ; }

private_keeps() { # git.repo -> sets pkeeps
    local repo=$1 ary=$2
    local keep keeps=("$repo"/objects/pack/pack-$SHA1.keep)
    pkeeps=()
    for keep in "${keeps[@]}" ; do
        is_ourkeep "$keep" || pkeeps=("${pkeeps[@]}" "$keep")
    done
}

is_tooclose() { [ "$(($1 * $RATIO))" -gt "$2" ] ; } # smaller larger

unique() { # [args...] > unique_words
    local lines=$(while [ $# -gt 0 ] ; do echo "$1" ; shift ; done)
    lines=$(echo "$lines" | sort -u)
    echo $lines  # as words
}

outfs() { # fs [args...] > argfs...
    local fs=$1 ; shift
    [ $# -gt 0 ] && echo -n "$1" ; shift
    while [ $# -gt 0 ] ; do echo -n "$fs$1" ; shift ; done
}

sort_list() { # < list > formatted_list
    # n has_keep size sha repack down up note
    awk '{ note=$8; for(i=8;i<NF;i++) note=note " "$(i+1)
           printf("%-5s %s %-14s %-40s %s %s %s %s\n", \
                     $1,$2,   $3,  $4, $5,$6,$7,note)}' |\
        sort -k 3,3n -k 1,1n
}

is_touched() { # git.repo
    local repo=$1
    local loose keep ours newer
    [ -n "$SW_T" ] && { debug "$SW_T -> treat as touched" ; return 0 ; }

    if [ -z "$SW_LOOSE" ] ; then
        # If there are loose objects, they may need to be pruned,
        # run even if nothing has really been touched.
        loose=$(find "$repo/objects" -type d \
                      -wholename "$repo/objects/[0-9][0-9]"
                      -print -quit 2>/dev/null)
        [ -n "$loose" ] && { info "There are loose object directories" ; return 0 ; }
    fi

    # If we don't have a keep, the current packfiles may not have been
    # compressed with the current gc policy (gc may never have been run),
    # so run at least once to repack everything.  Also, we need a marker
    # file for timestamp tracking (a dir needs to detect changes within
    # it, so it cannot be a marker) and our keeps are something we control,
    # use them.
    for keep in "$repo"/objects/pack/pack-$SHA1.keep ; do
        is_ourkeep "$keep" && { ours=$keep ; break ; }
    done
    [ -z "$ours" ] && { info 'We have no keep (we have never run?): run' ; return 0 ; }

    debug "Our timestamp keep: $ours"
    # The wholename stuff seems to get touched by a noop git gc
    newer=$(find "$repo/objects" "$repo/refs" "$repo/packed-refs" \
                  '!' -wholename "$repo/objects/info" \
                  '!' -wholename "$repo/objects/info/*" \
                  -newer "$ours" \
                  -print -quit 2>/dev/null)
    [ -z "$newer" ] && return 1

    info "Touched since last run: $newer"
    return 0
}

touch_refs() { # git.repo start_date refs
    local repo=$1 start_date=$2 refs=$3
    (
        debug "Setting start date($start_date) on unpacked refs:"
        debug "$refs"
        cd "$repo/refs" || return
        # safe to assume no newlines in a ref name
        echo "$refs" | xargs -d '\n' -n 1 touch -c -d "$start_date"
    )
}

set_start_date() { # git.repo start_date refs refdirs packedrefs [packs]
    local repo=$1 start_date=$2 refs=$3 refdirs=$4 packedrefs=$5 ; shift 5
    local pack keep idx repacked

    # This stuff is touched during object packs
    while [ $# -gt 0 ] ; do
        pack=$1 ; shift
        keep="$(keep_for "$pack")"
        idx="$(idx_for "$pack")"
        touch -c -d "$start_date" "$pack" "$keep" "$idx"
        debug "Setting start date on: $pack $keep $idx"
    done
    # This will prevent us from detecting any deletes in the pack dir
    # since gc ran, except for private keeps which we are checking
    # manually.  But there really shouldn't be any other relevant deletes
    # in this dir which should cause us to rerun next time, deleting a
    # pack or index file by anything but gc would be bad!
    debug "Setting start date on pack dir: $start_date"
    touch -c -d "$start_date" "$repo/objects/pack"


    if [ -z "$SW_REFS" ] ; then
        repacked=$(find "$repo/packed-refs" -newer "$repo/objects/pack"
                      -print -quit 2>/dev/null)
        if [ -n "$repacked" ] ; then
            # The ref dirs and packed-ref files seem to get touched even on
            # a noop refpacking
            debug "Setting start date on packed-refs"
            touch -c -d "$start_date" "$repo/packed-refs"
            touch_refs "$repo" "$start_date" "$refdirs"

            # A ref repack does not imply a ref change, but since it is
            # hard to tell, simply assume so
            if [ "$refs" != "$(cd "$repo/refs" ; find -depth)" ] || \
               [ "$packedrefs" != "$(<"$repo/packed-refs")" ] ; then
                # We retouch if needed (instead of simply checking then
                # touching) to avoid a race between the check and the set.
                debug "  but refs actually got packed, so retouch packed-refs"
                touch -c "$repo/packed-refs"
            fi
        fi
    fi
}

note_consolidate() { # note entry > note (no duplicated consolidated entries)
    local note=$1 entry=$2
    local entries=() ifs=$IFS
    if  echo "$note" | grep -q 'Consolidate with:[0-9,c]' ; then
        IFS=,
        entries=( $(echo "$note" | sed -es'/^.*Consolidate with:\([0-9,c]*\).*$/\1/') )
        note=( $(echo "$note" | sed -es'/Consolidate with:[0-9,c]*//') )
        IFS=$ifs
    fi
    entries=( $(unique "${entries[@]}" "$entry") )
    echo "$note Consolidate with:$(outfs , "${entries[@]}")"
}

note_toofar() { # note entry > note (no duplicated "too far" entries)
    local note=$1 entry=$2
    local entries=() ifs=$IFS
    if  echo "$note" | grep -q '(too far from:[0-9,c]*)' ; then
        IFS=,
        entries=( $(echo "$note" | sed -es'/^.*(too far from:\([0-9,c]*\)).*$/\1/') )
        note=( $(echo "$note" | sed -es'/(too far from:[0-9,c]*)//') )
        IFS=$ifs
    fi
    entries=( $(unique "${entries[@]}" "$entry") )
    echo "$note (too far from:$(outfs , "${entries[@]}"))"
}

last_entry() { # isRepack pline repackline > last_rows_entry
    local size_hit=$1 pline=$2 repackline=$3
    if [ -n "$pline" ] ; then
        if [ -n "$size_hit" ] ; then
            echo "$repack_line"
        else
            echo "$pline"
        fi
    fi
}

init_list() { # git.repo > shortlist
    local repo=$1
    local file
    local n has_keep size sha repack

    packs_sizes "$1" | {
        while read size file ; do
            n=$((n+1))
            repack=n
            has_keep=-
            if has_keep "$file" ; then
                has_keep=k
                has_ourkeep "$file" && has_keep=o
            fi
            sha=$(sha_for "$file")
            echo "$n $has_keep $size $sha $repack"
        done
    } | sort_list
}

consolidate_list() { # run < list > list
    local run=$1
    local sum=0 psize=0 sum_size=0 size_hit pn clist pline repackline
    local n has_keep size sha repack down up note

    {
        while read n has_keep size sha repack down up note; do
            [ -z "$up" ] && up='-'
            [ -z "$down" ] && down="-"

            if [ "$has_keep" = "k" ] ; then
                echo "$n $has_keep $size $sha $repack - - Private"
                continue
            fi

            if [ "$repack" = "n" ] ; then
                if is_tooclose $psize $size ; then
                    size_hit=y
                    repack=y
                    sum=$(($sum + $sum_size + $size))
                    sum_size=0 # Prevents double summing this entry
                    clist=($(unique "${clist[@]}" $pn $n))
                    down="^"
                    [ "$has_keep" = "-" ] && note="$note New +"
                    note=$(note_consolidate "$note" "$pn")
                elif [ "$has_keep" = "-" ] ; then
                    repack=y
                    sum=$(($sum + $size))
                    sum_size=0 # Prevents double summing this entry
                    clist=($(unique "${clist[@]}" $n))
                    note="$note New"
                elif [ $psize -ne 0 ] ; then
                    sum_size=$size
                    down="!"
                    note=$(note_toofar "$note" "$pn")
                else
                    sum_size=$size
                fi
            else
                sum_size=$size
            fi

            # By preventing "c files" (consolidated) from being marked
            # "repack" they won't get keeps
            repack2=y
            [ "${n/c}" != "$n" ] && { repack=- ; repack2=- ; }

            last_entry "$size_hit" "$pline" "$repack_line"
            # Delay the printout until we know whether we are
            # being consolidated with the entry following us
            # (we won't know until the next iteration).
            # size_hit is used to determine which of the lines
            # below will actually get printed above on the next
            # iteration.
            pline="$n $has_keep $size $sha $repack $down $up $note"
            repack_line="$n $has_keep $size $sha $repack2 $down v $note"

            pn=$n ; psize=$size # previous entry data
            size_hit='' # will not be consolidated up

        done
        last_entry "$size_hit" "$pline" "$repack_line"

        [ $sum -gt 0 ] && echo "c$run - $sum [$(outfs , "${clist[@]}")] - - -"

    } | sort_list
}

process_list() { # git.repo > list
    local list=$(init_list "$1")  plist run=0

    while true ; do
        plist=$list
        run=$((run +1))
        list=$(echo "$list" | consolidate_list "$run")
        if [ "$plist" != "$list" ] ; then
            debug "------------------------------------------------------------------------------------"
            debug "$HEADER"
            debug "$list"
        else
            break
        fi
    done
    debug "------------------------------------------------------------------------------------"
    echo "$list"
}

repack_list() { # git.repo < list
    local repo=$1
    local start_date newpacks=0 pkeeps keeps=1 refs refdirs rtn
    local packedrefs=$(<"$repo/packed-refs")

    # so they don't appear touched after a noop refpacking
    if [ -z "$SW_REFS" ] ; then
        refs=$(cd "$repo/refs" ; find -depth)
        refdirs=$(cd "$repo/refs" ; find -type d -depth)
        debug "Before refs:"
        debug "$refs"
    fi

    # Find a private keep snapshot which has not changed from
    # before our start_date so private keep deletions during gc
    # can be detected
    while ! array_equals pkeeps "${keeps[@]}" ; do
       debug "Getting a private keep snapshot"
       private_keeps "$repo"
       keeps=("${pkeeps[@]}")
       debug "before keeps: ${keeps[*]}"
       start_date=$(date)
       private_keeps "$repo"
       debug "after keeps: ${pkeeps[*]}"
    done

    while read n has_keep size sha repack down up note; do
        if [ "$repack" = "y" ] ; then
            keep="$repo/objects/pack/pack-$sha.keep"
            info "Repacking $repo/objects/pack/pack-$sha.pack"
            [ -f "$keep" ] && rm -f "$keep"
        fi
    done

    ( cd "$repo" && git gc "${GC_OPTS[@]}" ) ; rtn=$?

    # Mark any files withoug a .keep with our .keep
    packs=("$repo"/objects/pack/pack-$SHA1.pack)
    for pack in "${packs[@]}" ; do
        if keep "$pack" ; then
            info "New pack: $pack"
            newpacks=$((newpacks+1))
        fi
    done

    # Record start_time.  If there is more than 1 new packfile, we
    # don't want to risk touching it with an older date since that
    # would prevent consolidation on the next run.  If the private
    # keeps have changed, then we should run next time no matter what.
    if [ $newpacks -le 1 ] || ! array_equals pkeeps "${keeps[@]}" ; then
        set_start_date "$repo" "$start_date" "$refs" "$refdirs" "$packedrefs" "${packs[@]}"
    fi

    return $rtn # we really only care about the gc error code
}

git_gc() { # git.repo
    local list=$(process_list "$1")
    if [ -z "$SW_V" ] ; then
        info "Running $PROG on $1.  git gc options: ${GC_OPTS[@]}"
        echo "$HEADER" >&2
        echo "$list" >&2 ;
    fi
    echo "$list" | repack_list "$1"
}


PROG=$(basename "$0")
HEADER="Id Keep Size           Sha1(or consolidation list)      Actions(repack down up note)"
KEEP=git-exproll
HEX='[0-9a-f]'
HEX10=$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX
SHA1=$HEX10$HEX10$HEX10$HEX10

RATIO=10
SW_N='' ; SW_V='' ; SW_T='' ; SW_REFS='' ; SW_LOOSE='' ; GC_OPTS=()
while [ $# -gt 0 ] ; do
    case "$1" in
        -u|-h)  usage ;;
        -n)  SW_N="$1" ;;
        -v)  SW_V="$1" ;;

        -t)  SW_T="$1" ;;
        --norefs)  SW_REFS="$1" ;;
        --noloose) SW_LOOSE="$1" ;;

        -r|--ratio)  shift ; RATIO="$1" ;;

        *)  [ $# -le 1 ] && break
            GC_OPTS=( "${GC_OPTS[@]}" "$1" )
            ;;
    esac
    shift
done


REPO="$1"
if ! is_repo "$REPO" ; then
    REPO=$REPO/.git
    is_repo "$REPO" || usage "($1) is not likely a git repo"
fi


if [ -z "$SW_N" ] ; then
    is_touched "$REPO" || { info "Repo untouched since last run" ; exit ; }
    git_gc "$REPO"
else
    is_touched "$REPO" || info "Repo untouched since last run, analyze anyway."
    process_list "$REPO" >&2
fi
