2

I'm trying to rewrite git repository history and apply new pre-commit hook:

  1. Take every commit
  2. Apply pre-commit hook
  3. Keep the original metadata (author, date, message)
  4. Resolve conflicts manually, if any (the hook can alter the commit)
  5. Commit to a new repo

The end state is a new repo with a different commit history.

What I already found:

  • cherry-pick doesn't run pre-commit hook.
  • I can do
git cherry-pick --no-commit
git commit --no-edit

But it doesn't preserve the commit date. Also, not sure how to do that for each commit in history (unless I write a e.g. Python script for that).

Any ideas on how to do that efficiently?

Dennis Golomazov
  • 16,269
  • 5
  • 73
  • 81
  • 2
    To avoid asking about XY problem, please explain why you need to do so, because "common" way to apply new hook is to introduce it with one commit and store its hash in `.git-blame-ignore-revs` file to exclude these modification from `git blame` output. (Changes will look like after hook application, bt without reference to it; dates are shown from older commit, so it matches your expectations) – STerliakov Oct 22 '22 at 12:02
  • @SUTerliakov I want to rewrite history and remove extra newlines from being added to text files in the first place. I know I could remove them in a new commit, but I'd prefer removing them from history as well. I have a pre-commit hook that does that; now I need to apply that hook to historical commits and re-commit them. – Dennis Golomazov Oct 30 '22 at 19:21

2 Answers2

2

Use the --exec flag to git rebase, possibly with a custom GIT_SEQUENCE_EDITOR to skip the interactive prompt with the pick list. So something like:

GIT_SEQUENCE_EDIT=cat git rebase --root --exec .git/hooks/pre-commit

This will add exec .git/hooks/pre-commit after every pick <commit> in the pick list. If the pre-commit hooks fails, that will interrupt the rebase:

Executing: .git/hooks/pre-commit
warning: execution failed: .git/hooks/pre-commit
You can fix the problem, and then run

  git rebase --continue

You can manually resolve the issues, and then git rebase --continue.

larsks
  • 277,717
  • 41
  • 399
  • 399
0

Using @larsks's idea, I achieved the goal using the following commands. I'm leaving it here for reference.

  1. Create an empty branch [1]:
git switch --orphan new_history
  1. Rebase the main branch onto it, executing pre-commit hook on every commit:
git rebase --root main --exec .git/hooks/pre-commit -X theirs

Note that I used theirs merge strategy, so that in case of conflicts, the version from the main branch would be used ([2], [3]).

  1. After that, the commit date was set to today in all commits. To fix that, I ran ([4]):
git filter-branch --env-filter 'export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"' 

Here is my pre-commit script:

#!/bin/sh

set -x 

IFS=$'\n'

files=$(git diff-tree --no-commit-id  --name-status -r HEAD | grep -v ^D | cut -c3-)

if [ "$files" != "" ]
then

  for f in $files
  do
      if [[ "$f" =~ [.]md$ ]]
      then

          # Add a linebreak to the file if it doesn't have one
          if [ "$(tail -c1 $f)" != '\n' ]
          then
            echo >> $f
          fi

          # Remove trailing whitespace if it exists
          if grep -q "[[:blank:]]$" $f
          then
            sed -i "" -e $'s/[ \t]*$//g' $f
          fi

          # Remove blank lines
          python3 ~/bin/remove_empty_lines_md.py $f $f 
      fi
  done

fi

unset IFS

git add -A
git commit --amend --no-edit --no-verify

Note that --no-verify in the end is important (it skips hooks), otherwise there is an infinite loop.

For completeness, here is the remove_empty_lines_md.py script:

def remove_empty_lines(text):
    r"""
    >>> text = "\n\n- test\n  - test2\n\n- test3\n\n\n  cointinue\n\n- test 4\n\n- test 5\n  - test 6\n    continue\n    continue\n\n\n\n    test7\n\n\n    tset8\n\n\n- test 9\n\n\n- final\n\n\n"
    >>> remove_empty_lines(text)
    '- test\n  - test2\n- test3\n\n\n  cointinue\n- test 4\n- test 5\n  - test 6\n    continue\n    continue\n\n\n\n    test7\n\n\n    tset8\n- test 9\n- final\n'
    """    
    new_lines = []
    buffer = []
    for i, line in enumerate(text.split('\n')):
        if not line.strip():
            buffer.append(line)
        else:
            if buffer:
                if line.lstrip() and line.lstrip()[0] != '-':
                    new_lines.extend(buffer)
                buffer = []
            new_lines.append(line)
    return '\n'.join(new_lines) + '\n'


if __name__ == '__main__':
    import doctest
    import sys
    doctest.testmod()
    filename_in, filename_out = sys.argv[1], sys.argv[2]
    with open(filename_in) as fin:
        text = fin.read()
    with open(filename_out, 'w') as fout:
        fout.write(remove_empty_lines(text))
Dennis Golomazov
  • 16,269
  • 5
  • 73
  • 81