Featured image of post Git - preserve history when moving files

Git - preserve history when moving files

When moving files/directories around in a git repo, the version history of the file gets lost! Let’s fix that.

While working on my vSummary github repo, I came to a point where I had decided to change the folder structure of it. At this point I had about 60-ish commits to it. So I began by using the git mv command to move and rename directories then finished off with the usual commit and push commands. However, when I took a look at some of the individual files in the repo I noticed that the commit history for them were gone. While github still had all my commits, the history for individual files/directories seemed to have been erased with the restructuring commit. I did not expect that, but according to github, this is the expected behaviour: Why does github not track renames

Fortunately, I was able to fix this by rolling back my latest commit and using this gist script to preserve the history of my restructuring: https://gist.github.com/emiller/6769886.js

#!/bin/bash
#
# git-mv-with-history -- move/rename file or folder, with history.
#
# Moving a file in git doesn't track history, so the purpose of this
# utility is best explained from the kernel wiki:
#
#   Git has a rename command git mv, but that is just for convenience.
#   The effect is indistinguishable from removing the file and adding another
#   with different name and the same content.
#
# https://git.wiki.kernel.org/index.php/GitFaq#Why_does_Git_not_.22track.22_renames.3F
#
# While the above sucks, git has the ability to let you rewrite history
# of anything via `filter-branch`. This utility just wraps that functionality,
# but also allows you to easily specify more than one rename/move at a
# time (since the `filter-branch` can be slow on big repos).
#
# Usage:
#
#   git-rewrite-history [-d/--dry-run] [-v/--verbose] <srcname>=<destname> <...> <...>
#
# After the repsitory is re-written, eyeball it, commit and push up.
#
# Given this example repository structure:
#
#   src/makefile
#   src/test.cpp
#   src/test.h
#   src/help.txt
#   README.txt
#
# The command:
#
#   git-rewrite-history README.txt=README.md  \     <-- rename to markdpown
#                       src/help.txt=docs/    \     <-- move help.txt into docs
#                       src/makefile=src/Makefile   <-- capitalize makefile
#
#  Would restructure and retain history, resulting in the new structure:
#
#    docs/help.txt
#    src/Makefile
#    src/test.cpp
#    src/test.h
#    README.md
#
# @author emiller
# @date   2013-09-29
#

function usage() {
  echo "usage: `basename $0` [-d/--dry-run] [-v/--verbose] <srcname>=<destname> <...> <...>"
  [ -z "$1" ] || echo $1

  exit 1
}

[ ! -d .git ] && usage "error: must be ran from within the root of the repository"

dryrun=0
filter=""
verbose=""
repo=$(basename `git rev-parse --show-toplevel`)

while [[ $1 =~ ^\- ]]; do
  case $1 in
    -d|--dry-run)
      dryrun=1
      ;;

    -v|--verbose)
      verbose="-v"
      ;;

    *)
      usage "invalid argument: $1"
  esac

  shift
done

for arg in $@; do
  val=`echo $arg | grep -q '=' && echo 1 || echo 0`
  src=`echo $arg | sed 's/\(.*\)=\(.*\)/\1/'`
  dst=`echo $arg | sed 's/\(.*\)=\(.*\)/\2/'`
  dir=`echo $dst | grep -q '/$' && echo $dst || dirname $dst`

  [ "$val" -ne 1  ] && usage
  [ ! -e "$src"   ] && usage "error: $src does not exist"

  filter="$filter                            \n\
    if [ -e \"$src\" ]; then                 \n\
      echo                                   \n\
      if [ ! -e \"$dir\" ]; then             \n\
        mkdir -p ${verbose} \"$dir\" && echo \n\
      fi                                     \n\
      mv $verbose \"$src\" \"$dst\"          \n\
    fi                                       \n\
  "
done

[ -z "$filter" ] && usage

if [[ $dryrun -eq 1 || ! -z $verbose ]]; then
  echo
  echo "tree-filter to execute against $repo:"
  echo -e "$filter"
fi

[ $dryrun -eq 0 ] && git filter-branch -f --tree-filter "`echo -e $filter`"

Once you are finished with your history rewrites, you can force a push like: git push origin master --force

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo, using a modified version of the Stack theme.