Git: my git stash -p optimization in Git 2.55

Optimizing your doohickeys is highly important.

I got another commit merged in Git 2.55 (2026-06-29), yay! The release note reads:

"git stash -p" has been optimized by reusing cached index entries in its temporary index, avoiding unnecessary lstat() calls on unchanged files.

Here is the story of this change.

Patch mode slowness

The -p (--patch) option makes git stash interactive. Instead of stashing everything, Git walks you through your uncommitted changes hunk by hunk, asking whether to stash each one, using the same interactive machinery as git add -p. (I covered this option, and many more tips, in my book Boost Your Git DX.)

I love using git stash -p to store parts of my changes, such as to leave unrelated edits for committing later on another branch, or to temporarily remove a bug fix to check that my new tests fail without it. But I found the command was slow in my client Rippling’s monorepo, taking about 35 seconds to even show the first prompt. Every time I ran it, I sat there in frustration, or cancelled it and used the clunkier git stash -- <pathspecs> form instead.

Writing the commit

One day, I decided I’d had enough and fired an LLM at the Git source tree to analyze the problem. I had a good idea that git stash -p could be optimized, because git add -p and git restore -p didn’t suffer the same slowness, despite sharing the same interactive loop. Between my general knowledge of Git and its internals, and the dogged persistence of my AI agent assistants, I tracked down the culprit.

The issue turned out to be the temporary index that git stash -p builds to record your selected hunks. It was created by spawning a git read-tree HEAD subprocess, in this bit of stash_patch():

cp_read_tree.git_cmd = 1;
strvec_pushl(&cp_read_tree.args, "read-tree", "HEAD", NULL);
strvec_pushf(&cp_read_tree.env, "GIT_INDEX_FILE=%s",
             stash_index_path.buf);
if (run_command(&cp_read_tree)) {
    ret = -1;
    goto done;
}

That produced an index with no cached file stat data and no fsmonitor validity bits. So when the patch-selection machinery later refreshed that index, Git had to lstat() every single file in the working tree before showing the first prompt. That was ~200,000 files in the Rippling monorepo.

The fix was to build the temporary index in-process instead, copying the cached index entries for all the files that match HEAD. With the cached data preserved, the refresh can skip unchanged files, and fsmonitor can do its job.

Through about ten sessions with a mix of GPT 5.5 and Claude 4.8, and manual edits, I honed in on a commit that fixed the issue, matched Git’s coding and writing styles, and passed the test suite. The core change is a new function in builtin/stash.c that creates the temporary index, with oneway_merge() reusing the existing cache entries for paths that match HEAD:

static int create_index_from_tree(const struct object_id *tree_id,
                                  const char *index_path)
{
    ...

    opts.head_idx = 1;
    opts.src_index = the_repository->index;
    opts.dst_index = &dst_istate;
    opts.merge = 1;
    opts.reset = UNPACK_RESET_PROTECT_UNTRACKED;
    opts.fn = oneway_merge;

    if (unpack_trees(nr_trees, t, &opts)) {
        ret = -1;
        goto done;
    }

    if (write_locked_index(&dst_istate, &lock_file, COMMIT_LOCK)) {
        ret = error(_("unable to write new index file"));
        goto done;
    }

    ...
}

stash_patch() then calls this function in place of its previous git read-tree HEAD subprocess.

The result was a 53× speedup, dropping the time-to-first-prompt in Rippling’s monorepo from 34.774 seconds to 0.659 seconds. Finally, the command was usable in mega repos.

Review, performance tests, and merge

I submitted the patch to the Git mailing list as PR #2306 through GitGitGadget, Git’s mailing list bridge on GitHub. There, I received some lovely in-depth review from Junio Hamano, the Git maintainer, in the thread. (Linus Torvalds created Git in April 2005 but handed maintenance over to Junio in July that year, so Junio is basically the true Git god.)

It turned out that both the LLMs and I had missed Git’s framework for performance tests, in the t/perf directory. So I added a performance test for git stash -p, in a new file t/perf/p3904-stash-patch.sh:

#!/bin/sh

test_description="Performance tests for git stash -p"

. ./perf-lib.sh

test_perf_fresh_repo

test_expect_success "setup" '
    mkdir files &&
    test_seq 1 100000 | while read i; do
        echo "content $i" >files/$i.txt || return 1
    done &&
    git add files/ &&
    git commit -q -m "add tracked files" &&
    echo modified >files/1.txt
'

test_perf "stash -p, no fsmonitor" \
    --setup 'echo modified >files/1.txt' '
    printf "q\n" | git stash -p >/dev/null 2>&1 || true
'

...

test_done

(I’ve elided a second test_perf case that repeats the test with fsmonitor enabled.)

This test let me verify the speedup in a reproducible way: in the test repository with 100,000 files, the mean time dropped from 6.90 to 0.55 seconds without fsmonitor, and from 6.83 to 0.28 seconds with it.

After a couple of rounds of review, the commit was merged, and was released in Git 2.55, back in June.

Githubber Taylor Blau’s Highlights from Git 2.55 post is worth reading to know everything that happened in the release. Git sees hundreds of small fixes and optimizations per release, so naturally a performance improvement like mine doesn’t appear in that post—it’s hiddend in “the rest of the iceberg”.

Fin

If you use git stash -p, I hope you enjoy this speedup. And if you don’t yet, give it a whirl.

Thanks to Rippling for providing the tokens and time to work on this optimization!

Stash it, patch it, hunk it, wait it, time it, fix it, optimize it,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: