#!/usr/bin/env bash
# generate man page with: txt2man.sh -t git-fresh <(./git-fresh -?) > git-fresh.1

# git-fresh
# https://github.com/imsky/git-fresh
# By Ivan Malopinsky - http://imsky.co
# MIT License

usage () {
cat << EOT
NAME
  git-fresh

SYNOPSIS
  git-fresh [-fmrtRWS] [-sl] [remote] [root]

DESCRIPTION
  git-fresh helps keep your Git repo fresh.

  By default, git-fresh will:
  - update local root (master) to match remote root
  - stash changes
  - prune remote branches

  git-fresh will ignore any branches listed in a .freshignore file.
  freshignore should contain branch names you would like to ignore
  on separate lines. The file can exist in the current Git repo
  or in the home directory, i.e. ~/.freshignore.

  remote is origin by default. root is master by default.

OPTIONS
  -f  Delete stale local and remote branches
  -m  Merge remote root into current branch
  -r  Rebase current branch against remote root
  -t  Remove local tags that do not exist on remote
  -R  Reset local root to remote root
  -W  Wipe workspace clean
  -S  Clear all stash entries

  -s  Apply stashed changes after run
  -l  Only delete local stale branches

  -v  Print git-fresh version and exit

BUGS
  Issues are tracked on GitHub: https://github.com/imsky/git-fresh

AUTHOR
  Ivan Malopinsky - http://imsky.co
EOT

exit 0
}

say () {
  echo "[git-fresh] $@" 1>&2
}

die () {
  say $@
  exit 1
}

error () {
  ERR=${ERR:-unknown}
  die "Error on line $1: $ERR"
}

trap 'error $LINENO' ERR

if [[ "$1" = '--help' ]]; then
  usage
fi

while getopts ":fmrtslRWSTv" opt; do
  case $opt in
    f)
      FORCE_DELETE_STALE=true
      ;;
    m)
      MERGE=true
      ;;
    r)
      REBASE=true
      ;;
    t)
      TAGS=true
      ;;
    s)
      APPLY_STASH=true
      ;;
    l)
      DELETE_ONLY_LOCAL=true
      ;;
    R)
      RESET_ROOT=true
      ;;
    W)
      WIPE_WORKSPACE=true
      ;;
    S)
      CLEAR_STASH=true
      ;;
    T)
      TEST=true
      ;;
    v)
      VERSION=1.12.1
      ;;
    *)
      usage
      break
      ;;
  esac
done

shift $((OPTIND-1))

# Are we in version mode?

if [[ ! -z $VERSION ]]; then
  echo git-fresh $VERSION
  exit 0
fi

# Are we in testing mode?

if [[ $TEST = true ]]; then
  PATH=$(pwd):$PATH
  TEST_DIR=/tmp/git-fresh-test
  fail_test () {
    echo 'Tests failed!'
    rm -rf $TEST_DIR
    exit 1
  }

  rm -rf $TEST_DIR; mkdir -p $TEST_DIR; cd $TEST_DIR
  git init; touch test; git add test; git commit -am 'test'
  git checkout -b test; rm test; git commit -am 'delete test'
  git checkout master; git merge test; git checkout test
  git-fresh -fr; git rev-parse --verify test && fail_test || true
  git checkout -b test; git checkout master; git-fresh; git checkout -
  git rev-parse --abbrev-ref HEAD | grep -q test || fail_test
  rm -rf $TEST_DIR
  echo 'Tests passed!'
  exit 0
fi

# Are we inside a git repository?

INSIDE_GIT_REPO=$(git rev-parse --is-inside-work-tree 2> /dev/null)

if [[ -z "$INSIDE_GIT_REPO" ]]; then
  die "Not a git repository"
fi

# Are we in a non-empty git repository?
ERR="could not get top-level-directory"
TOP_LEVEL_DIRECTORY=$(git rev-parse --show-toplevel)
REMOTE=${1:-origin}

ROOT_GUESS=master

if [[ -n $(git show-ref refs/heads/main) && -z $(git show-ref refs/heads/master) ]]; then
  ROOT_GUESS=main
fi

ROOT=${2:-$ROOT_GUESS}

# Recover the root HEAD if it is missing or corrupt (e.g. master head reads "master")
recover_root () {
  ROOT_HEAD_FILE="$TOP_LEVEL_DIRECTORY/.git/refs/heads/$ROOT"

  if [[ -e "$ROOT_HEAD_FILE" ]]; then
    if [[ $(cat "$ROOT_HEAD_FILE") = $(echo $ROOT) ]]; then
      CORRUPT_ROOT_HEAD=true
    fi
  else
    MISSING_ROOT_HEAD=true
  fi

  if [[ "$CORRUPT_ROOT_HEAD" = "true" || "$MISSING_ROOT_HEAD" = "true" ]]; then
    ERR="failed to recover $ROOT HEAD"
    RECOVERED_ROOT_HEAD=$(cat "$TOP_LEVEL_DIRECTORY/.git/logs/refs/heads/$ROOT" | tail -n1 | cut -d' ' -f2)
    echo "$RECOVERED_ROOT_HEAD" > "$ROOT_HEAD_FILE"
    say "Recovered $ROOT HEAD, set to $RECOVERED_ROOT_HEAD"
    CORRUPT_ROOT_HEAD=false
    MISSING_ROOT_HEAD=false
  fi
}

recover_root

LAST_WORKING_DIRECTORY="$(pwd)"
cd "$TOP_LEVEL_DIRECTORY"

ERR="could not get current commit"
CURRENT=$(git rev-parse --abbrev-ref HEAD)
ERR=""

if [[ $(git remote -v | wc -l) -gt "0" ]]; then
  REMOTES=true
fi

# Is this branch in .freshignore?

FRESH_IGNORE="$TOP_LEVEL_DIRECTORY/.freshignore"

if [[ ! -f $FRESH_IGNORE ]]; then
  FRESH_IGNORE="~/.freshignore"
fi

if [[ -f $FRESH_IGNORE ]]; then
    if [[ ! -z $(grep -Fx "$CURRENT" "$FRESH_IGNORE") ]]; then
        die "Branch $CURRENT is ignored"
    fi
fi

STASH_STAMP=git-fresh-$(date +%s)

# Stash changed files

if ! git diff-files --quiet; then
  ERR="could not stash changes"
  git stash save $STASH_STAMP
fi

if [[ $REMOTES = true ]]; then
  # Update remotes and prune stale remotes
  ERR="could not update and prune remotes"
  git remote prune $REMOTE
  git remote update $REMOTE
  git remote prune $REMOTE
fi

# If we are not already on root branch, switch to root branch (master)

if [[ "$ROOT" != "$CURRENT" ]]; then
  ERR="could not check out $ROOT branch"
  git checkout $ROOT > /dev/null 2>&1
fi

# Wipe workspace?

if [[ $WIPE_WORKSPACE = true ]]; then
  ERR="could not wipe workspace"
  git clean -dfx
fi

if [[ $REMOTES = true ]]; then
  # Reset root?

  if [[ $RESET_ROOT = true ]]; then
    ERR="could not reset root"
    git reset --hard $REMOTE/$ROOT
  fi

  ERR="could not perform fast forward merge"
  git pull --quiet --ff-only $REMOTE $ROOT || say "Fast forward merge failed on $ROOT. You can reset local $ROOT by running git fresh -R."
fi

# Compute stale branches

ERR="could not determine stale branches"
SMART_STALE=$(git branch -a --merged | tr -d "\* " | grep -Ev ">|$ROOT" | cat)

LOCAL_STALE=$(grep -Ev "^remotes/" <<< "$SMART_STALE" | cat)
REMOTE_STALE=$(grep -E "^remotes/" <<< "$SMART_STALE" | cat)
#todo: add flag to prune all remote branches
REMOTE_STALE=$(grep "^remotes/$REMOTE" <<< "$REMOTE_STALE" | cat)
REMOTE_STALE=${REMOTE_STALE//remotes\/$REMOTE\/}

if [[ ! -z "${SMART_STALE// }" ]]; then
  if [[ ! -z "${LOCAL_STALE// }" ]]; then
    STALE_BRANCHES=true
    if [[ -f "$FRESH_IGNORE" ]]; then
      LOCAL_STALE=$(echo -n $LOCAL_STALE | tr " " "\n" | grep -Fxvf "$FRESH_IGNORE" | tr "\n" " ")
      if [[ -z $LOCAL_STALE ]]; then
        STALE_BRANCHES=false
      fi
    fi
    if [[ "$FORCE_DELETE_STALE" = true ]]; then
      ERR="could not delete stale local branches: $LOCAL_STALE"
      echo -n $LOCAL_STALE | tr " " "\0" | xargs -0 git branch -d 2> /dev/null
    else
      if [[ $STALE_BRANCHES = true ]]; then
        say "Local stale branches found:" $(echo -n $LOCAL_STALE | tr "\n" " ")
      fi
    fi
  fi

  if [[ ! -z "${REMOTE_STALE// }" ]]; then
    STALE_BRANCHES=true
    if [[ -f "$FRESH_IGNORE" ]]; then
      REMOTE_STALE=$(echo -n $REMOTE_STALE | tr " " "\n" | grep -Fxvf "$FRESH_IGNORE" | tr "\n" " ")
      if [[ -z $REMOTE_STALE ]]; then
        STALE_BRANCHES=false
      fi
    fi
    if [[ "$FORCE_DELETE_STALE" = true ]]; then
      if [[ "$DELETE_ONLY_LOCAL" != true ]]; then
        ERR="could not delete stale remote branches: $REMOTE_STALE"
        echo -n $REMOTE_STALE | tr " " "\0" | xargs -0 git push $REMOTE --delete
      fi
    else
      if [[ $STALE_BRANCHES = true ]]; then
        say "Remote stale branches found:" $(echo -n $REMOTE_STALE | tr "\n" " ")
      fi
    fi
  fi

  if [[ "$FORCE_DELETE_STALE" != true && "$STALE_BRANCHES" = true ]]; then
    say "Delete stale branches with: git fresh -f"
  fi
fi

# Remove tracking information for missing upstreams

if [[ ! -z $(git branch -vv | grep -F "[$REMOTE/$CURRENT: gone]") ]]; then
  git branch --unset-upstream $CURRENT
fi

# Rebase or merge remote root against local branch

if [[ ! -z $(git rev-parse --verify --quiet "$CURRENT") ]]; then
  if [[ "$ROOT" != "$CURRENT" ]]; then
    ERR="could not check out $CURRENT branch"
    git checkout $CURRENT
    recover_root
  fi

  if [ "$REBASE" = true ] && [ "$MERGE" = true ]; then
    say "Rebase and merge enabled, skipping both"
  else
    if [[ "$REMOTES" = true ]]; then
      if [[ "$REBASE" = true ]]; then
        ERR="could not rebase against $ROOT branch"
        git rebase $ROOT
      fi

      if [[ "$MERGE" = true ]]; then
        ERR="could not merge $ROOT branch"
        git merge --no-edit $ROOT
      fi
    fi
  fi
else
  echo "$CURRENT branch was stale, staying on $ROOT"
fi

# Remove local tags that are missing on the remote

if [[ "$TAGS" = true ]]; then
  ERR="could not get remote tags"
  REMOTE_TAGS=$(git ls-remote --tags $REMOTE | cut -f 2)
  LOCAL_TAGS=$(git show-ref --tags | cut -d' ' -f 2)

  for tag in $LOCAL_TAGS; do
    if [[ -z $(grep $tag <<< "$REMOTE_TAGS" | cat) ]]; then
      MISSING_TAG="${tag//refs\/tags\/}"
      git tag -d $MISSING_TAG
    fi
  done
fi

# Restore stashed changes

if [[ ! -z $(git stash list | grep $STASH_STAMP | cat) ]]; then
  if [[ "$APPLY_STASH" = true ]]; then
    ERR="could not apply stashed changes"
    git stash pop
  else
    say "Stashed changes present, apply with: git stash pop"
  fi
fi

# Clear stashed changes

if [[ "$CLEAR_STASH" = true ]]; then
  ERR="could not clear stashed changes"
  git stash clear
fi

if ! git gc --auto --force; then
  ERR="git prune failed"
  git prune
  rm -rf "$TOP_LEVEL_DIRECTORY/.git/gc.log"
fi

if [[ -d "$LAST_WORKING_DIRECTORY" ]]; then
    cd "$LAST_WORKING_DIRECTORY"
else
    say "Previous working directory does not exist on the branch $ROOT"
fi

recover_root

ERR=""
