Skip to content

필사 모드: git history — Git이 rebase 옆에 놓은 실험적 history 재작성 명령어

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

들어가며 — rebase 옆에 문이 하나 더 생겼다

세 번째 전 커밋에 빠뜨린 한 줄을 넣고 싶다. 지금 Git으로 이걸 하려면 손이 이렇게 갑니다 — git commit --fixup으로 fixup 커밋을 만들고, git rebase --autosquash로 접고, 그 위에 쌓아둔 브랜치들이 있다면 --update-refs가 제대로 먹었는지 확인한다. 커밋 하나를 둘로 쪼개는 건 더 심합니다. 인터랙티브 rebase를 띄우고, 인스트럭션 시트에서 edit으로 바꾸고, git reset HEAD~로 커밋을 풀고, 두 번 나눠 스테이징해서 두 번 커밋하고, rebase를 끝낸다.

Git 2.54에 이 작업들을 겨냥한 새 명령어가 실험적으로 들어왔습니다. 이름은 git history입니다. 2.55에서 서브커맨드가 하나 더 붙었고요. 이 글은 실제로 뭐가 들어왔는지, 밑바닥이 어떻게 지어졌는지, 그리고 어디서 막히는지를 릴리스 노트·문서·커밋 메시지·소스 같은 1차 출처로 확인해서 정리합니다.

먼저 결론부터 말하면, 이건 "rebase를 대체하는 물건"이 아닙니다. 문서가 스스로 그렇게 말합니다.

무엇이, 언제 들어왔나

타임라인은 이렇습니다. 날짜는 git/git 저장소의 태그 tagger 날짜 기준입니다.

시점내용
2026-01-13Patrick Steinhardt가 builtin: add new "history" command 커밋으로 명령어 도입 (reword 포함)
2026-03-02split 서브커맨드 구현
2026-04-20Git 2.54.0 릴리스 — git historyreword + split로 처음 배포됨
2026-04-27fixup 서브커맨드 구현
2026-06-29Git 2.55.0 릴리스 — fixup 추가

2.54 릴리스 노트의 표현은 딱 한 줄입니다 — "git history" history rewriting (experimental) command has been added. 2.55 노트도 한 줄이고요 — "git history" learned "fixup" command. 릴리스 노트가 이 정도로 건조하다는 게 오히려 현재 상태를 잘 말해 줍니다.

2.55 기준 현재 서브커맨드는 셋입니다.

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 — 해당 커밋의 메시지만 고칩니다. 나머지는 그대로.
  • split — 커밋을 인터랙티브하게 둘로 쪼갭니다. hunk를 골라 새 부모 커밋으로 빼냅니다. (이 글에서 hunk는 원어로 씁니다. 뒤에 나오는 훅/hook과 헷갈리지 않게요.)
  • fixup스테이징된 변경을 지정한 커밋에 밀어 넣습니다. 문서 표현으로는 git commit --fixup=<commit> 다음에 git rebase --autosquash <commit>~를 한 것과 성격이 비슷합니다.

git history split HEAD를 실행하면 git add -p와 같은 hunk 선택 프롬프트가 뜨고, 고른 hunk가 새 부모 커밋으로 빠집니다. 문서의 예시에서 bar만 골라 빼내면 원본 커밋은 foo만 남고, 빠져나온 bar 커밋이 그 부모가 됩니다. 전부 고르거나 아무것도 안 고르는 건 한쪽이 빈 커밋이 되므로 거부됩니다.

왜 만들었나 — 커밋 메시지에 적혀 있다

동기를 추측할 필요가 없습니다. 도입 커밋 메시지에 Steinhardt가 직접 적어 뒀습니다. rebase로 흔히 하는 작업들(커밋 순서 뒤집기, 커밋 쪼개기, 커밋 버리기, 여러 커밋 합치기, 팁이 아닌 커밋 수정하기)을 나열한 뒤 이렇게 씁니다.

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.

그리고 결정적인 문단이 이어집니다.

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.

Git 커밋 메시지가 Jujutsu를 "경쟁 VCS"로 호명하면서, 거기서 흔해진 워크플로가 사용자 수요의 증거라고 말하고 있습니다. 이 정도로 노골적인 인정은 흔치 않습니다.

split 커밋 메시지는 더 실무적입니다. 커밋 쪼개기의 기존 7단계 절차를 쭉 적어 놓고 이렇게 맺습니다.

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

즉 이 명령어의 타깃은 "Git 전문가가 아닌 사람"입니다. 문서도 같은 말을 합니다 — git history는 rebase보다 더 의견이 강하고(opinionated) 더 단순한 방법을 지향하고, 커밋 범위를 다른 베이스에 얹거나 여러 커밋을 한 번에 편집하고 싶으면 rebase를 쓰라고 명시합니다.

rebase와 실제로 뭐가 다른가

문서가 꼽는 차이는 셋인데, 하나씩 뜯어볼 값이 있습니다.

1. 워킹 트리와 인덱스를 안 건드린다. rewordsplit베어 저장소에서도 동작합니다. fixup만 예외인데, 스테이징된 변경을 인덱스에서 읽어야 하기 때문입니다. rebase가 매 커밋마다 워킹 트리를 갈아엎는 것과 대비됩니다.

2. 자손 브랜치를 기본으로 갱신한다. --update-refs=branches가 기본값이고, 원본 커밋의 자손인 로컬 브랜치가 전부 재작성된 커밋을 가리키도록 옮겨집니다. rebase의 --update-refs는 명시적으로 켜야 하는 옵션이지만, 여기선 기본 동작입니다. --update-refs=headHEAD만 옮길 수도 있습니다.

3. 훅을 실행하지 않는다. 문서 원문은 이렇습니다 — git-history(1) does not execute any githooks(5) at the current point in time. This may change in the future. 이건 장점이 아니라 그냥 현재 상태입니다. 뒤에서 다시 다룹니다.

여기에 문서에 없는, 소스를 봐야 보이는 차이가 하나 더 있습니다. ref 갱신이 원자적입니다. builtin/history.c는 갱신 대상 ref를 전부 모아 단일 ref_transaction에 담고 마지막에 한 번 커밋합니다. 스택 브랜치 다섯 개가 걸려 있으면 다섯 개가 다 옮겨지거나 하나도 안 옮겨집니다. 중간에 깨진 상태가 남지 않습니다.

--dry-run도 실용적입니다. ref를 건드리지 않고 git update-ref --stdin이 그대로 먹을 수 있는 형식으로 갱신 계획을 출력합니다. 필요한 객체는 이미 저장소에 써 두기 때문에, 출력된 걸 나중에 적용해도 안전하다고 문서는 설명합니다. 재작성 결과를 미리 보고 싶을 때 쓸 만합니다.

fixup의 세 갈래 병합은 어느 방향인가

fixup이 재미있는 부분입니다. 소스의 주석이 스스로를 설명합니다.

/*
 * 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);

방향을 정리하면 이렇습니다.

base   (머지 베이스) = HEAD의 트리
ours   (side1)      = 대상 커밋의 트리
theirs (side2)      = 인덱스 트리 (= HEAD 트리 + 스테이징된 변경)

왜 이게 맞는지 생각해 보면 깔끔합니다. base가 HEAD이고 theirs가 인덱스이므로, theirs가 base 대비 가진 변경분은 정확히 당신이 스테이징한 diff입니다. 그리고 ours는 대상 커밋의 트리고요. 그래서 이 병합의 결과는 "대상 커밋의 트리 + 스테이징한 diff"가 됩니다. 스테이징한 변경을 과거 커밋 위로 체리픽하는 것과 같습니다. 주석이 "basically the same logic as a cherry-pick"이라고 말하는 이유입니다.

핵심은 merge_incore_nonrecursive라는 이름의 incore입니다. 병합이 전부 메모리에서 일어나고 워킹 트리를 거치지 않습니다. rebase가 커밋마다 파일을 체크아웃했다 되돌리는 것과 근본적으로 다른 지점입니다.

병합이 깨끗하지 않으면 어떻게 될까요. 코드가 아주 짧게 답합니다.

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

그냥 중단합니다. 이건 버그가 아니라 설계입니다. 이 얘기는 뒤에서 이어집니다.

--empty 옵션도 이 맥락에서 나옵니다. fixup 결과로 커밋이 비어 버리는 경우가 둘 있는데 — 스테이징한 변경이 대상 커밋의 변경을 정확히 상쇄하거나, 자손 커밋이 방금 조상에 밀어 넣은 것과 같은 변경을 담고 있었거나 — 기본값 drop은 빈 커밋을 없애고, keep은 남기고, abort는 에러로 멈춥니다. 다만 루트 커밋을 드롭하는 건 아직 안 됩니다. 소스의 TODO 주석은 이걸 "기술적 제약일 뿐 존재할 이유가 없는 제한"이라고 적어 놨습니다.

밑바닥 — sequencer가 아니라 git replay 위에 지었다

이 부분이 이 기능에서 가장 조용하지만 중요한 결정입니다.

git history가 자손 커밋들을 다시 얹는 일은 replay_revisions()가 합니다. git replay는 2023년 Elijah Newren이 도입해 2.44.0에 처음 실린 실험적 명령어로, 커밋 범위를 새 베이스에 얹되 "워킹 트리와 인덱스를 건드리지 않고" ref는 "원자적 트랜잭션으로" 갱신하는 물건입니다. git history가 물려받은 성질이 정확히 이것들입니다.

그런데 git replay는 원래 빌트인 명령어였지 라이브러리가 아니었습니다. 그래서 Steinhardt는 git history를 만들기 전에 길을 먼저 닦았습니다. 2026-01-13 같은 날 올라온 패치 시리즈 순서를 보면 의도가 그대로 드러납니다.

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

먼저 git replay의 핵심 로직을 libgit.a로 빼내고, 빈 커밋 범위와 분리된 HEAD 지원을 replay에 채워 넣은 다음, 그 위에 git history를 얹었습니다. 새 포슬린을 만들려고 기존 플러밍을 라이브러리로 승격시킨 겁니다.

이게 왜 눈에 띄냐면, 2025년 Git 기여자 서밋에서 논의된 방향과 정확히 맞아떨어지기 때문입니다. Taylor Blau가 2025-10-06에 메일링 리스트에 올린 서밋 노트 중 "The future of history rewriting" 세션(진행: Phillip Wood)에는 이런 대목이 있습니다.

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

그리고 merge-ort와 replay를 쓴 Elijah Newren의 입장이 이렇게 기록돼 있습니다 — 시퀀서를 넘어서고 싶다, 시퀀서는 워킹 트리를 너무 자주 갱신해서 바람직하지 않다, git historygit replay를 쓰는 쪽으로 갈 수 있다. 당시 노트에는 "일단 시퀀서 underpinning으로 실험 버전을 랜딩해 보는" 안도 적혀 있었고, 결론은 "UI를 먼저 랜딩하고 밑바닥은 나중에 반복하자"였습니다.

실제로 배포된 코드를 확인해 보면 결과가 나옵니다. v2.55.0의 builtin/history.csequencer.h를 include하긴 하지만, 거기서 쓰는 건 커밋 메시지 정리 헬퍼인 cleanup_message() 하나뿐입니다. rebase 시퀀서 머신은 호출하지 않습니다. 재작성은 replay_revisions()가, 병합은 merge_incore_nonrecursive()가 합니다. 즉 서밋에서 논의된 "시퀀서 말고 replay" 방향으로 이미 지어져 나왔습니다.

이건 사용자에게 실질적 차이로 돌아옵니다. 시퀀서 기반이었다면 워킹 트리 체크아웃과 상태 파일(.git/rebase-merge/ 같은)이 따라왔을 테고, 베어 저장소 동작은 불가능했을 겁니다.

정직한 한계 — 이건 아직 실험이다

여기서부터가 실제로 중요한 부분입니다. 문서가 LIMITATIONS 섹션을 따로 두고 있고, 그 내용이 가볍지 않습니다.

머지 커밋이 있으면 못 쓴다. 원문은 This command does not (yet) work with histories that contain merges. 대신 git rebase --rebase-merges를 쓰라고 안내합니다. 소스에는 대상 커밋의 자손 중에 부모가 둘 이상인 커밋이 있는지 미리 리브워크로 확인하고 replaying merge commits is not supported yet!으로 거부하는 코드가 있습니다. 머지 커밋이 섞인 브랜치에서 일한다면 이 명령어는 지금 당신 것이 아닙니다.

충돌이 나면 그냥 중단한다. 이건 미구현이 아니라 명시적 설계 선택입니다. 문서 원문이 이유까지 답니다.

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.

"history 재작성은 상태를 갖는 작업이 아니어야 한다"는 게 핵심입니다. git rebase가 충돌 나면 rebase --continue를 기다리며 저장소를 어정쩡한 상태로 붙잡아 두는 그 모델을, git history는 아예 거부합니다. 대신 성공 아니면 무변경입니다. 괄호 안의 (if)도 눈여겨볼 만합니다 — 저자 본인이 확정으로 쓰지 않았습니다.

훅이 안 돈다. 정책을 훅으로 강제하는 팀이라면 이건 조용한 함정입니다. git history로 재작성한 커밋은 훅을 우회합니다. 서밋 노트에 이 긴장이 왜 있는지 힌트가 있는데, --update-refs가 추가됐을 때 훅이 깨졌던 전례가 언급되고 "그걸 또 반복하고 싶지 않다"는 얘기가 나옵니다. 지금은 아예 안 돌리는 쪽을 택한 상태이고, 문서는 "미래에 바뀔 수 있다"고 열어 뒀습니다.

루트 커밋 드롭 불가. --empty=drop으로 루트 커밋이 비게 되는 경우는 아직 안 됩니다.

그리고 가장 미묘한 지점 하나. 문서는 EXPERIMENTAL이라고 대문자로 외치는데, 런타임에는 아무 경고도 없습니다. command-list.txt에서 git-historymainporcelain으로 등록돼 있어서 git help의 주요 명령 목록에 그냥 나옵니다. 설정 플래그로 잠겨 있지도 않고, 실행하면 경고 한 줄 없이 그냥 동작합니다. git replay가 SYNOPSIS에 (EXPERIMENTAL!)을 박아 둔 것과 비교하면 표식이 더 약합니다. 서밋 노트의 "실험 버전을 랜딩해서 사람들이 써 보게 하자, 호환성은 약속하지 않는다"는 결론을 생각하면 의도된 상태로 보이지만, 스크립트에 넣기 전에 알고는 있어야 합니다. 문서가 THE BEHAVIOR MAY CHANGE.라고 한 걸 그대로 믿는 게 안전합니다.

막힌 곳의 이름 — first-class conflicts

fixup 커밋 메시지의 마지막 문단이 이 글의 실마리를 다시 묶어 줍니다.

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.

"두 번째 항목"은 충돌 시 중단을 말합니다. 그러니까 이 명령어의 유용성이 제한되는 걸 저자도 알고 있고, 그 해법으로 first-class conflicts를 지목합니다. git history 문서도 같은 걸 지목했고요.

first-class conflicts는 충돌을 "해결할 때까지 붙잡아 두는 임시 상태"가 아니라 커밋할 수 있는 일급 객체로 다루는 모델입니다. jj가 이렇게 동작합니다 — 충돌난 상태를 그대로 커밋하고, 그 위에 계속 작업하고, 나중에 부분적으로 풀 수 있습니다. 그러면 fixup이 충돌을 만나도 중단할 필요가 없어집니다. 충돌을 기록하고 넘어가면 되니까요.

그래서 이게 Git에 들어올 가능성이 있느냐. 2025년 기여자 서밋에 이 주제가 세션으로 올라왔고, 진행자가 Martin von Zweigbergk였습니다. jj를 만든 사람입니다. 노트에 기록된 논의 몇 대목이 분위기를 보여 줍니다.

  • Elijah Newren은 스택 중간을 편집하면서 컨텍스트를 저장해 두거나, 충돌 해결을 동료에게 넘기기 위해 first-class conflicts를 원한다고 말합니다. 큰 충돌을 나눠서 정복할 수 있다는 게 이유입니다.
  • jj가 지금 쓰는 방식도 설명됩니다 — 트리에 특수 헤더를 쓰고 OS가 숨기는 디렉터리에 .left/, .right/ 같은 관례로 충돌을 저장하며, 그 특수 헤더가 있으면 클라이언트가 푸시를 거부합니다. jj가 굳이 이 특수 트리를 쓰는 이유는 충돌 객체가 GC되면 안 되기 때문이고, Git이 스스로 그걸 안 지우는 법을 배우면 jj가 부릴 마법이 줄어든다는 얘기가 나옵니다.
  • 충돌을 트리가 아니라 커밋 헤더에 담자는 안도 논의됩니다. 헤더에 있으면 트리 전체를 걷지 않고도 충돌 여부를 알 수 있다는 장점이 언급되고요.
  • 그리고 "이게 들어오면 Git 명령어 여럿이 낡은 게 되지 않느냐"는 질문에, Elijah는 이미 rebase를 걷어내고 replay로 다시 시작하는 작업을 하고 있다고 답하고, Patrick이 git history도 같은 얘기라고 덧붙입니다.

노트의 마지막 줄은 이렇습니다.

sounds like no broad opposition should we aim for 3.0?

폭넓은 반대는 없었고, Git 3.0을 겨냥해 볼까 하는 질문으로 세션이 끝납니다. 여기서 분명히 해 둘 것 — 이건 합의된 로드맵이 아니라 서밋에서 나온 질문입니다. 일정도, 확정된 설계도 없습니다. 문서가 once (if) Git learns about first-class conflicts라고 쓴 것과 같은 온도로 읽어야 맞습니다.

그래서, 지금 써야 하나

써 볼 만한 경우

  • Git 2.55 이상이고, 선형 history에서 일한다.
  • 스택 브랜치를 쌓아 두고 중간 커밋을 자주 고친다. 자손 브랜치를 원자적으로 같이 옮겨 주는 게 여기서 실제 이득입니다.
  • 메시지 오타 수정(reword)처럼 위험이 거의 없는 작업. 베어 저장소에서도 되는 게 덤입니다.
  • 커밋 쪼개기(split)가 손에 안 익어서 매번 7단계 절차를 검색하고 있다.

아직 아닌 경우

  • history에 머지 커밋이 섞여 있다. 그냥 동작하지 않습니다.
  • 충돌이 예상되는 재작성. 중단될 뿐이니 rebase를 쓰는 게 맞습니다.
  • 훅으로 정책을 강제하는 팀. 훅이 안 돕니다.
  • 스크립트나 CI에 박아 넣는 것. 문서가 동작이 바뀔 수 있다고 명시했습니다.
  • 이미 jj를 쓰고 있다. jj가 하는 일의 부분집합이고, jj는 충돌을 중단 없이 다룹니다.

처음 쓸 때는 --dry-run을 붙여서 어떤 ref가 어디로 움직일지 먼저 보는 걸 권합니다. 어차피 객체는 써 두므로 확인 비용이 싸고, 재작성이 손에 익기 전까지는 이게 가장 확실한 안전벨트입니다. git reflog가 여전히 뒤를 봐 준다는 것도 기억해 두면 좋습니다 — Git이 데이터를 어떻게 붙잡아 두는지는 Git은 당신의 데이터를 어떻게 저장하는가 편에서 다뤘습니다.

마치며

정리하면 이렇습니다. git history는 rebase의 대체재가 아니라, rebase로 하기엔 번거로웠던 몇 가지 흔한 작업을 위한 의견이 강한 좁은 도구입니다. 2.54에 reword/split으로 들어왔고 2.55에 fixup이 붙었습니다. 워킹 트리를 안 건드리고, 자손 브랜치를 원자적으로 옮기고, 밑바닥은 시퀀서가 아니라 git replay + 인코어 merge-ort로 지어졌습니다.

동시에 이건 정직하게 미완성입니다. 머지도 못 다루고, 충돌이 나면 멈추고, 훅도 안 돕니다. 그리고 그 한계들이 어디서 풀릴지도 문서와 커밋 메시지에 이미 적혀 있습니다 — first-class conflicts. 그 논의를 서밋에서 이끈 사람이 jj를 만든 Martin von Zweigbergk였다는 것, 그리고 도입 커밋이 동기로 Jujutsu를 직접 지목했다는 것이, 지금 Git에서 벌어지는 일을 요약합니다. 경쟁 VCS가 먼저 증명한 워크플로가 상류로 역류하는 중입니다.

그래서 지금의 git history는 "완성된 기능"이라기보다 랜딩된 질문에 가깝습니다. UI를 먼저 내놓고 반응을 보자는 서밋의 결론 그대로고요. 써 보고 불편한 곳이 있으면, 그게 아직 메일링 리스트에서 정해지는 중인 답일 수 있습니다.

참고 자료

현재 단락 (1/114)

세 번째 전 커밋에 빠뜨린 한 줄을 넣고 싶다. 지금 Git으로 이걸 하려면 손이 이렇게 갑니다 — `git commit --fixup`으로 fixup 커밋을 만들고, `git r...

작성 글자: 0원문 글자: 11,064작성 단락: 0/114