Updating local commits with more changes in Git (the harder way)

March 2, 2025

One of the things I do with Git is maintain personal changes locally on top of the upstream version, with my changes updated via rebasing every time I pull upstream to update it. In the simple case, I have only a single local change and commit, but in more complex cases I split my changes into multiple local commits; my local version of Firefox currently carries 12 separate personal commits. Every so often, upstream changes something that causes one of those personal changes to need an update, without actually breaking the rebase of that change. When this happens I need to update my local commit with more changes, and often it's not the 'top' local commit (which can be updated simply).

In theory, the third party tool git-absorb should be ideal for this, and I believe I've used it successfully for this purpose in the past. In my most recent instance, though, git-absorb frustratingly refused to do anything in a situation where it felt it should work fine. I had an additional change to a file that was changed in exactly one of my local commits, which feels like an easy case.

(Reading the git-absorb readme carefully suggests that I may be running into a situation where my new change doesn't clash with any existing change. This makes git-absorb more limited than I'd like, but so it goes.)

In Git, what I want is called a 'fixup commit', and how to use it is covered in this Stackoverflow answer. The sequence of commands is basically:

# modify some/file with new changes, then
git add some/file

# Use this to find your existing commit ID
git log some/file

# with the existing commid ID
git commit --fixup=<commit ID>
git rebase --interactive --autosquash <commit ID>^

This will open an editor buffer with what 'git rebase' is about to do, which I can immediately exit out of because the defaults are exactly what I want (assuming I don't want to shuffle around the order of my local commits, which I probably don't, especially as part of a fixup).

I can probably also use 'origin/main' instead of '<commit ID>^', but that will rebase more things than is strictly necessary. And I need the commit ID for the 'git commit --fixup' invocation anyway.

(Sufficiently experienced Git people can probably put together a script that would do this automatically. It would get all of the files staged in the index, find the most recent commit that modified each of them, abort if they're not all the same commit, make a fixup commit to that most recent commit, and then potentially run the 'git rebase' for you.)


Comments on this page:

Long time reader, first time caller, and the author of git-absorb. If the line offsets of the hunks don't overlap, then they commute and nothing will be absorbed. But if you want to absorb at the granularity of whole files instead, there's -w. It's intentional that this is not the default behavior - eg it wouldn't make sense for a long branch touching the same file repeatedly - but it might be appropriate for this use case.

Hi Chris,

Why even use a third-party tool for this? I solve this problem often. (5 steps, but worth it every time)

Step 1: Add your additional commit on top.

Step 2: Run bash$ git rebase --interactive $REBASE_COMMIT ;

Step 3: You are now in a text editor. Move the line of your new additional commit above (below?) the commit you want to "change destructively" and change its type to "fixup". Move your personal commit lines to the top.

Step 4: Exit the text editor. Everything should apply cleanly now.

Step 5: Profit. (You are done.)

Nota bene: The only git manual ever worth reading was John Wiegley's Git from the Bottom Up.

Nota bene: This kind of rebasing, together with email patches shows that going with commit hashes is actually a bit ridiculous. People (and git) should be mostly interested in tree hashes. Git already knows them. It just decides against showing them. Those are solely content based. Commit hashes change when the commit time changes. There are cases where this is useful, but they are rarer than people seem to use them in practice. Got porcelain is mostly sharp edges. Will now go bandage my hand. Again.

By cks at 2025-03-03 14:12:44:

Thanks for the comment, tummychow; I missed the -w argument because it's omitted from the manual page in the current Fedora package (although 'git-absorb -h' includes it). I'll have to remember to check 'git-absorb -h' or the online manual page in the future.

Yeah, sorry about the manpage. A contributor has been generously burning down a bunch of old issues (including that) but I haven't cut a new release yet. Will likely do so in the next ~week.

By Jonas at 2025-03-26 13:47:33:

I believe this is what "git commit --amend" does? It removes the last commit and re-commits it with the added information.

You also seldom need to find out the last commit id. That's what HEAD is. But HEAD is mostly redundant since it is often the default.

FWIW, though it is of course very convient to have a tool that automatically figures out which commits to fix up, a bit of dumber tooling makes it at least not painfully inconenient to do the rest of the job manually. For the longest time I’ve had this alias:

git config --global alias.fu '!c=`git rev-parse --short "$1"` && shift && git commit -m "fixup! $c" "$@" && :'

This exists to enable the following ~/bin/git-autorebase command, which (somewhat akin to git merge-base) answers the question “which commit do I have to rebase from in order for all of my fixups to get applied?” and thereby allows me to say git rebase --autosquash `git autorebase`:

#!/usr/bin/perl
use strict; use warnings;

my $hash = '[0-9a-f]{40}';

open my $pipe, '-|', qw( git rev-list --topo-order --oneline --no-abbrev-commit HEAD )
	or die "Couldn't open pipe to git rev-list: $!\n";

my ( $in_startup, %target ) = 1;

while ( readline $pipe ) {
	my ( $sha1, $msg ) = /^($hash) (.*)/o or die "Malformed input: $_";

	my $have_match = $msg =~ s/^(?:fixup|squash)! +(?=[^ ])//;

	unless ( $in_startup or $have_match or %target ) {
		print $sha1, "\n";
		last;
	}

	while ( my ( $k, $rx ) = each %target ) {
		delete $target{ $k } if $_ =~ $rx;
	}

	if ( $have_match ) {
		$in_startup = 0;
		my $rx = quotemeta $msg;
		$target{ $msg } = qr/^(?i:$rx)|^$hash $rx/;
	}
}

die "No autosquash/autofixup commits found\n"
	if $in_startup;

die "Invalid autosquash/autofixup commit(s): @{[ sort keys %target ]}\n"
	if %target;

(What this does is it scans the history from the HEAD commit backwards until it encounters a “fixup!” or “squash!” commit, then scans backwards further, picking up any additional fixup or squash commits along the way, until it has encountered all the commits that the fixups and squashes belong to, and then spits out the first commit beyond that.)

I haven’t gotten git absorb to work for me, but provided that it writes its fixup commits with commit hashes, git autorebase will seamlessly work together with it as well, in which case you could mix and match any of these tools.

Written on 02 March 2025.
« Using PyPy (or thinking about it) exposed a bug in closing files
If you get the chance, always run more extra network fiber cabling »

Page tools: View Source, View Normal.
Search:
Login: Password:

Last modified: Sun Mar 2 22:34:31 2025
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.