Renaming The Past With Git

By | 2013-10-11

I added a directory to a repository I was working on; made some commits to the files in that directory, added some files, removed some files, then decided that I didn’t like what I’d named it.

When you make a mistake like this in files with git, you can easily use git rebase -i to move a fixup commit into the past so that your mistake appears to have never happened (assuming you haven’t pushed your branch yet). Renaming a file works okay too; git’s pretty good at tracking that change. Renaming a whole directory, especially with adds and removals is harder.

The solution is git filter-branch, which lets you apply alterations to all commits in a sequence of revisions. It’s particularly versatile, but also fairly complicated to use.

I’m going to use the --index-filter mode of git filter-branch as it’s fast and leaves your working directory out of it.

First we’ll prepare our filter; we can do this mostly non-destructively.

$ git ls-files -s
100644 afe54f7da2cd579c8ef46abf90a2e9223e637baf 0       OLDDIR/.gitignore
100644 aa343b709899207effae27e9ce1b877de3ae2aec 0       OLDDIR/Makefile
100644 c9943eee519617204de5c156ea62266e18188b84 0       OLDDIR/README.md

The filenames here are prefixed with a tab, that makes it easy to write a sed command that alters them.

$ git ls-files -s | sed "s|\tOLDIR\(.*\)|\tNEWDIR\1|"
100644 afe54f7da2cd579c8ef46abf90a2e9223e637baf 0       NEWDIR/.gitignore
100644 aa343b709899207effae27e9ce1b877de3ae2aec 0       NEWDIR/Makefile
100644 c9943eee519617204de5c156ea62266e18188b84 0       NEWDIR/README.md

What we’ve done here is made a list that gives new names to particular contents (remember that git identified content by its hash, not by its name). This list is exactly the right format to feed into git update-index.

$ git ls-files -s | sed 's|\tOLDIR\(.*\)|\tNEWDIR\1|' \
    | git update-index --index-info

If you run git status after running this command you’ll see that Git has listed all the renames as if they are new files. That’s because we only updated the existing index, leaving all the current OLDDIR/ entries in place. What we need to do is create a new index then replace the old with it – the new index will not include any old listings so git will see that as a rename rather than an add.

$ git ls-files -s | sed 's|\tOLDIR\(.*\)|\tNEWDIR\1|' \
    | GIT_INDEX_FILE=.git/tempnewindex git update-index --index-info \
    && mv .git/tempnewindex .git/index

After this you’ll see:

$ git status
# On branch temp
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       renamed:    OLDDIR/.gitignore -> NEWDIR/.gitignore
#       renamed:    OLDDIR/Makefile -> NEWDIR/Makefile
#       renamed:    OLDDIR/README.md -> NEWDIR/README.md
#
# Changes not staged for commit:
#   (use "git add/rm <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#       deleted:    NEWDIR/.gitignore
#       deleted:    NEWDIR/Makefile
#       deleted:    NEWDIR/README.md

The “not staged” deletions are because we didn’t change the working directory. Don’t worry about that, remember it’s only the index that gets committed.

We now use this command with git filter-branch to do the same thing for a series of commits. We need to make a slight modification to cope with git filter-branch’s use of subdirectories, by using the environment variable it sets for us $GIT_INDEX_FILE.

$ git filter-branch --index-filter 'git ls-files -s \
    | sed "s|\tOLDIR\(.*\)|\tNEWDIR\1|" \
    | GIT_INDEX_FILE=$GIT_INDEX_FILE.new git update-index --index-info \
        && mv "$GIT_INDEX_FILE.new" "$GIT_INDEX_FILE"' \
        HEAD^^^^..HEAD

This does the rename on the last four revisions.

Blink and you’ll miss it. I’d advise doing this on a temporary branch until you’re happy that it’s worked then git reset your master branch to the temporary to activate it (although Git will make a backup for you, so it’s not the end of the world if you don’t).

You’ll find almost exactly this final command written on the man page for git-filter-branch; but hopefully the above will let you build up to it gradually so you can test out the change (in particular the sed command line) before doing anything.

Be careful though – it’s not hard to lose any non-revision-controlled files you keep in these directories when you’re experimenting with this.

Leave a Reply