2

Suppose my repository has two files, f1 and f2, lines of each of which have been changed by multiple different commits. Now suppose my head commit, c3, changes line L_1_1 through L_1_2 of f1 and L_2_1 through L_2_2 of f2, which were last altered in commits c1 and c2 respectively. For simplicity, let's assume each complete range of lines was altered in its entirety by its corresponding commit.

Now, I want to break this commit up into two separate changes, and apply each of them as a fixup to c1 and c2, so that they each get the changes I made to the relevant files in c3, at the point where the relevant lines were last changed rather than in a separate commit.

Naturally, I can do this manually: A soft reset to c3~, committing each of the files separately, and rebasing with editing the todo-list to correspond to the fixups I want. But this is a bit cumbersome, especially if it's not just the case of 2 files, but a lot more.

How can I affect this change more conveniently? (If it's at all possible.)

Notes:

  • Naturally, I want to be able to do this not just for 2 files, but for many files.
  • c3, c2 and c1 are not consecutive commits, nor the only commits. c3 is the head, that's all you can assume.
einpoklum
  • 118,144
  • 57
  • 340
  • 684
  • 1
    For academic purposes, let's assume you have 100 files, and 100 commits each modifying its own separate file. Then commit 101 modifies all 100 files and you want to split that 101st commit into 100 separate fixup commits to their respective file's commit, without having to manually create 100 fixup commits. Did I capture the essence of the question properly? – TTT Feb 08 '23 at 17:30
  • @TTT: Yes, I'm a great fan of academia, you got the gist of it. – einpoklum Feb 08 '23 at 19:29

1 Answers1

1

I believe the answer is yes, this can be automated. The following algorithm should do what you're asking:

  1. Start with a clean git status. (No pending files.)
  2. Reset mixed, back one commit. Now all of the changes in that commit (c3) will be pending.
  3. Loop through each of the pending files presented by git diff --name-only.
  4. For each file: stage it, and commit it, using the --fixup option, and specify the commit ID you wish to squash this new commit into. You can use a function to determine that commit ID. The first sensible function that comes to mind is the last commit ID that modified the file before c3, for example:
git log --format="%H" -n1 <file-name> # last commit ID to modify <file-name>
  1. Now do an interactive rebase using autosquash, and set the editor to null so you aren't prompted to save and continue. This will automatically squash the new commits into their respective prior commits using fixup.

Here's an MRE in a Bash script that demonstrates with 5 files (though any number should work):

#!/bin/bash
git init
git config core.safecrlf false # don't display line ending warnings

# create some files, each in their own commit
for i in {1..5}
do
   echo "Creating file$i.txt..."
   echo "This is file $i" > file$i.txt
   git add .
   git commit -m "Add file$i.txt"
done

# modify each file
for i in {1..5}
do
   echo "Modifying file$i.txt..."
   echo "Another line to file $i" >> file$i.txt
done

# create a single commit with all of the file modifications
git add .
git commit -m "Modify all files"

echo ""
echo "Show current log:"
git --no-pager log --oneline # show current history
echo ""

# reset mixed back 1 commit
git reset @~1

# loop through pending files and create a fixup commit to the last commit modifying the file
git diff --name-only | \
    while read file;
    do
        # stage only this file
        git add $file
        # commit the add with fixup to the most recent commit modifying this file
        git commit --fixup $(git log --format="%H" -n1 $file) # this could be a function
        echo ""
    done
    
echo ""
echo "Show current log:"
git --no-pager log --oneline # show current history
echo ""

# interactive rebase with autosquash, without prompting for an editor
GIT_SEQUENCE_EDITOR=: git rebase -i --root --autosquash

echo ""
echo "Show current log:"
git --no-pager log --oneline # show current history
echo ""
TTT
  • 22,611
  • 8
  • 63
  • 69
  • With 5 files, on my mediocre laptop it took 12 seconds to run the script. For fun, I tried changing the (two places where there's a) 5 to 100, and then it took just under 3 minutes. This also made me realize I needed to add the `--no-pager` option to `git log`. ;) – TTT Feb 09 '23 at 06:38