Skip to content

필사 모드: git history — The Experimental History-Rewriting Command Git Put Next to rebase

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — Another Door Next to rebase

You want to add a line you left out of the commit three back. Here's what your hands have to do to pull that off in Git today — make a fixup commit with git commit --fixup, fold it in with git rebase --autosquash, and if you have branches stacked on top, check that --update-refs actually caught them. Splitting one commit into two is worse. Fire up an interactive rebase, change the instruction to edit, unstage the commit with git reset HEAD~, stage and commit it twice in two passes, then finish the rebase.

Git 2.54 added a new command aimed squarely at these tasks, experimentally. It's called git history. 2.55 tacked on one more subcommand. This post checks release notes, docs, commit messages, and source — primary sources — to lay out what actually shipped, how the foundation is built, and where it hits its limits.

The conclusion up front: this is not a "replacement for rebase." The documentation says so itself.

What Shipped, and When

Here's the timeline. Dates are the tagger dates on tags in the git/git repository.

DateDetail
2026-01-13Patrick Steinhardt introduces the command with the builtin: add new "history" command commit (includes reword)
2026-03-02split subcommand implemented
2026-04-20Git 2.54.0 released — git history first ships with reword + split
2026-04-27fixup subcommand implemented
2026-06-29Git 2.55.0 released — fixup added

The 2.54 release notes say it in exactly one line — "git history" history rewriting (experimental) command has been added. The 2.55 notes are one line too — "git history" learned "fixup" command. That the release notes are this dry actually says something about the current state of things.

As of 2.55, there are three subcommands.

git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
  • reword — fixes only that commit's message. Everything else stays as is.
  • split — interactively splits a commit into two. You pick hunks and pull them out into a new parent commit. (This post keeps "hunk" as-is throughout, so it doesn't get confused with "hook," which comes up later.)
  • fixup — pushes staged changes into the specified commit. In the documentation's own words, it's similar in character to running git commit --fixup=<commit> followed by git rebase --autosquash <commit>~.

Run git history split HEAD and you get a hunk-selection prompt just like git add -p; the hunks you pick get pulled out into a new parent commit. In the documentation's example, picking only bar to pull out leaves the original commit with just foo, and the extracted bar commit becomes its parent. Picking everything, or picking nothing, is rejected because one side would end up an empty commit.

Why It Was Built — It's Written in the Commit Message

No need to guess at the motivation. Steinhardt wrote it out himself in the introducing commit message. After listing the operations people commonly do with rebase — reordering commits, splitting a commit, dropping a commit, squashing several commits together, amending a commit that isn't the tip — he writes:

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions.

Then comes the decisive paragraph.

Rebases also do not update dependent branches. The use of stacked branches has grown quite common with competing version control systems like Jujutsu though, so it clearly is a need that users have.

A Git commit message calling Jujutsu a "competing VCS," and citing how common a workflow has become there as evidence of user demand — that's not something you see admitted this openly very often.

The split commit message is more down-to-earth. It lists out the existing seven-step procedure for splitting a commit, then closes with:

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

In other words, the target audience is "people who are not Git experts." The documentation says the same thing — git history aims to be a more opinionated, simpler way to work than rebase, and it explicitly tells you to reach for rebase instead if you want to move a range of commits onto a different base or edit several commits at once.

What Actually Differs From rebase

The documentation names three differences, and each is worth pulling apart.

1. It doesn't touch the working tree or the index. reword and split work even in a bare repository. fixup is the exception, since it has to read staged changes from the index. This is a contrast to rebase, which churns through the working tree on every commit.

2. It updates descendant branches by default. --update-refs=branches is the default, and every local branch that descends from the original commit gets moved to point at the rewritten commit. rebase's --update-refs is an option you have to turn on explicitly, but here it's the default behavior. You can also move only HEAD with --update-refs=head.

3. It runs no hooks. The documentation's own words: git-history(1) does not execute any githooks(5) at the current point in time. This may change in the future. This isn't a virtue — it's just the current state. More on this later.

There's one more difference the documentation doesn't mention — you only see it in the source. The ref update is atomic. builtin/history.c collects every ref that needs updating into a single ref_transaction and commits it once, at the end. If you have five stacked branches hanging off the commit, either all five move or none of them do. No broken halfway state is left behind.

--dry-run is practical too. It doesn't touch any refs, and prints the update plan in a format git update-ref --stdin can consume directly. The documentation explains that since the necessary objects are already written to the repository, it's safe to apply the printed output later. Useful when you want to preview the outcome of a rewrite.

Which Direction Does fixup's Three-Way Merge Run

This is the interesting part of fixup. The comment in the source explains itself.

/*
 * Perform the three-way merge to reapply changes in the index onto the
 * target commit. This is using basically the same logic as a
 * cherry-pick, where the base commit is our HEAD, ours is the original
 * tree and theirs is the index tree.
 */
init_basic_merge_options(&merge_opts, repo);
merge_opts.ancestor = "HEAD";
merge_opts.branch1 = argv[0];
merge_opts.branch2 = "staged";
merge_incore_nonrecursive(&merge_opts, head_tree,
                          original_tree, index_tree, &merge_result);

Here's the direction, laid out.

base   (merge base) = HEAD's tree
ours   (side1)      = the target commit's tree
theirs (side2)      = the index tree (= HEAD's tree + staged changes)

It's clean once you think through why this is right. Since base is HEAD and theirs is the index, what theirs has changed relative to base is exactly the diff you staged. And ours is the target commit's tree. So the result of this merge becomes "the target commit's tree + the diff you staged." That's the same as cherry-picking your staged changes onto a past commit — which is why the comment calls it "basically the same logic as a cherry-pick."

The key is the incore in the name merge_incore_nonrecursive. The merge happens entirely in memory and never touches the working tree — a fundamentally different point from rebase checking files out and reverting them for every commit.

What happens if the merge isn't clean? The code answers very briefly.

if (!merge_result.clean) {
    ret = error(_("fixup would produce conflicts; aborting"));
    goto out;
}

It just aborts. This isn't a bug — it's the design. More on this later.

The --empty option shows up in this context too. There are two cases where fixup leaves a commit empty — the staged changes exactly cancel out the target commit's changes, or a descendant commit already contained the same change you just pushed into an ancestor — and the default drop removes the empty commit, keep leaves it, and abort stops with an error. Dropping the root commit, though, still doesn't work. The source's TODO comment describes this as "a limitation that's purely technical and has no reason to exist."

The Foundation — Built on git replay, Not the Sequencer

This is the quietest but most important decision behind this feature.

The job of replaying descendant commits in git history is done by replay_revisions(). git replay is an experimental command Elijah Newren introduced in 2023, first shipped in 2.44.0, that puts a range of commits onto a new base "without touching the working tree or the index" and updates refs "in an atomic transaction." These are exactly the properties git history inherits.

But git replay was originally a builtin command, not a library. So Steinhardt paved the road before building git history. The order of the patch series posted on 2026-01-13 shows the intent plainly.

6aeda3cf5  builtin/replay: move core logic into "libgit.a"
410e37806  replay: small set of cleanups
542577156  replay: support empty commit ranges
48a72f61f  replay: support updating detached HEAD
475ade1cd  wt-status: provide function to expose status for trees
a675183d4  builtin: add new "history" command
d205234cb  builtin/history: implement "reword" subcommand

First, pull git replay's core logic out into libgit.a; fill in support for empty commit ranges and detached HEAD in replay; then build git history on top. Existing plumbing got promoted into a library in order to build a new porcelain.

Why this stands out is that it lines up exactly with the direction discussed at the 2025 Git Contributor Summit. In the summit notes Taylor Blau posted to the mailing list on 2025-10-06, the "The future of history rewriting" session (led by Phillip Wood) has this passage:

Git history vs git replay Replay is plumbing used by server. History is porcelain used by user.

And Elijah Newren, who wrote merge-ort and replay, is recorded taking this position — he wants to move past the sequencer, the sequencer updates the working tree too often to be desirable, and git history could go the route of using git replay. The notes from that session also mention an option to "land an experimental version on the sequencer underpinning for now," and the conclusion was "land the UI first, iterate on the foundation later."

Checking the actual shipped code gives you the answer. v2.55.0's builtin/history.c does include sequencer.h, but the only thing it uses from there is cleanup_message(), a commit-message cleanup helper. It never calls into the rebase sequencer machinery. The rewriting is done by replay_revisions(), the merging by merge_incore_nonrecursive(). In other words, it was already built in the "replay, not the sequencer" direction discussed at the summit.

This comes back to users as a real difference. Had it been sequencer-based, working-tree checkouts and state files (like .git/rebase-merge/) would have come along with it, and running on a bare repository would have been impossible.

The Honest Limitations — This Is Still an Experiment

This is where things actually matter. The documentation has a dedicated LIMITATIONS section, and its contents aren't trivial.

It doesn't work if there's a merge commit. The original text: This command does not (yet) work with histories that contain merges. It directs you to use git rebase --rebase-merges instead. The source checks in advance, via a revwalk, whether any descendant of the target commit has more than one parent, and rejects the operation with replaying merge commits is not supported yet!. If you work on a branch with merge commits mixed in, this command isn't for you right now.

If a conflict occurs, it just aborts. This isn't a missing feature — it's an explicit design choice. The documentation itself gives the reason.

Furthermore, the command does not support operations that can result in merge conflicts. This limitation is by design as history rewrites are not intended to be stateful operations. The limitation can be lifted once (if) Git learns about first-class conflicts.

The core of it is that "history rewrites shouldn't be stateful operations." git history rejects outright the model where git rebase, on hitting a conflict, leaves the repository sitting in limbo waiting for rebase --continue. Instead it's success or no change at all. The (if) in parentheses is worth noticing too — the author himself didn't write it as a certainty.

Hooks don't run. For a team that enforces policy through hooks, this is a quiet trap. Commits rewritten with git history bypass hooks entirely. The summit notes give a hint at why this tension exists — they mention a precedent where hooks broke when --update-refs was added, and the note that "we don't want to repeat that." For now, the choice made is to not run them at all, and the documentation leaves the door open, saying "this may change in the future."

Can't drop the root commit. The case where --empty=drop would leave the root commit empty still doesn't work.

And the subtlest point of all. The documentation shouts EXPERIMENTAL in capitals, but there's no warning at runtime at all. In command-list.txt, git-history is registered as mainporcelain, so it just shows up in git help's list of primary commands. It's not gated behind a config flag either — run it and it just works, without a single warning line. Compared to git replay, which stamps (EXPERIMENTAL!) right into its SYNOPSIS, the marking here is weaker. Given the summit notes' conclusion of "land an experimental version so people can try it, with no compatibility promise," this looks intentional — but you should know it before you put it in a script. It's safest to take the documentation's THE BEHAVIOR MAY CHANGE. at face value.

The Name of the Blocker — First-Class Conflicts

The last paragraph of the fixup commit message ties this post's threads back together.

Especially the second item limits the usefulness of this command a bit. But there are plans to introduce first-class conflicts into Git, which will help use cases like this one.

The "second item" refers to aborting on conflict. So the author knows this command's usefulness is limited, and points to first-class conflicts as the fix. The git history documentation points to the same thing.

First-class conflicts is a model that treats a conflict not as "a provisional state you hold onto until it's resolved" but as a first-class object you can commit. That's how jj works — it commits the conflicted state as is, lets you keep working on top of it, and lets you resolve it partially later. Under that model, fixup wouldn't need to abort on hitting a conflict — it could just record the conflict and move on.

So is there any chance this lands in Git? The topic got its own session at the 2025 Contributor Summit, led by Martin von Zweigbergk — the creator of jj. A few passages recorded in the notes give a sense of the mood.

  • Elijah Newren says he wants first-class conflicts so he can preserve context while editing the middle of a stack, or hand off conflict resolution to a colleague. The reason given is being able to divide and conquer a large conflict.
  • The notes also describe how jj currently does it — it writes a special header into the tree and stores the conflict using a .left/, .right/-style convention in an OS-hidden directory, and clients refuse to push when that special header is present. The reason jj bothers with this special tree is that conflict objects must not get garbage-collected, and the notes point out that once Git itself learns not to delete them, jj has less magic left to perform.
  • A proposal to store conflicts in the commit header rather than the tree comes up too, with the advantage noted that you can tell whether a conflict exists without walking the whole tree.
  • And to the question "wouldn't this make a bunch of Git commands obsolete if it lands," Elijah answers that he's already working on tearing out rebase and starting over with replay, and Patrick adds that git history is the same story.

The last line of the notes reads:

sounds like no broad opposition should we aim for 3.0?

No broad opposition, and the session ends on the question of whether to aim for Git 3.0. To be clear here — this is a question raised at the summit, not an agreed-upon roadmap. There's no timeline and no finalized design. It should be read with the same temperature as the documentation's once (if) Git learns about first-class conflicts.

So, Should You Use It Now

Worth trying

  • You're on Git 2.55 or later and work on linear history.
  • You stack branches and frequently fix up commits in the middle. Moving descendant branches atomically along with it is a real win here.
  • Near-zero-risk work like fixing a typo in a message (reword). Working in a bare repository too is a bonus.
  • Splitting a commit (split) never became muscle memory, and you look up the seven-step procedure every time.

Not yet

  • History has merge commits mixed in. It just won't work.
  • Rewrites where a conflict is expected. It'll just abort, so rebase is the right call.
  • A team that enforces policy through hooks. Hooks don't run.
  • Baking it into a script or CI. The documentation explicitly states the behavior may change.
  • You're already using jj. This is a subset of what jj does, and jj handles conflicts without aborting.

The first time you use it, attach --dry-run and see which refs would move where before committing. Objects get written either way, so checking is cheap, and until the rewrite becomes second nature, this is your surest seatbelt. It's also worth remembering that git reflog still has your back — how Git holds onto your data is covered in How Git Actually Stores Your Data.

Closing

To sum up: git history isn't a replacement for rebase — it's a narrow, opinionated tool for a handful of common tasks that were cumbersome to do with rebase. It landed with reword/split in 2.54, and fixup got added in 2.55. It doesn't touch the working tree, it moves descendant branches atomically, and its foundation is built on git replay plus in-core merge-ort rather than the sequencer.

At the same time, it's honestly unfinished. It can't handle merges, it stops on conflict, and hooks don't run. And where those limits will get resolved is already written into the documentation and the commit messages — first-class conflicts. That the person who led that discussion at the summit was Martin von Zweigbergk, the creator of jj, and that the introducing commit named Jujutsu directly as the motivation, sums up what's happening in Git right now. A workflow a competing VCS proved out first is flowing back upstream.

So git history as it stands now is less "a finished feature" and more a landed question. That's exactly the summit's conclusion of shipping the UI first and watching the reaction. If you try it and hit something uncomfortable, that friction might be an answer that's still being worked out on the mailing list.

References

현재 단락 (1/114)

You want to add a line you left out of the commit three back. Here's what your hands have to do to p...

작성 글자: 0원문 글자: 17,986작성 단락: 0/114