Jujutsu의 메가머지 워크플로가 무엇인지, 왜 유용한지, 그리고 별칭과 리베이스를 활용해 여러 작업 흐름을 동시에 관리하는 방법을 설명합니다.
이 글은 중급 Jujutsu 사용자와 Jujutsu에 호기심이 있는 Git 사용자를 모두 위해 쓰였습니다.
저는 Jujutsu를 아주 많이 사용하는 사람이고, 일상적인 개발에서 JJ 커뮤니티에서 흔히 “메가머지” 워크플로라고 부르는 방식에 점점 더 많이 의존하게 되었습니다. 의외로 소수의 파워 유저를 제외하면 이 방식은 거의 이야기되지 않아서, 이것이 어떤 모습인지와 왜 특히 복잡한 개발 환경에 있거나 작은 PR을 많이 내보내는 사람에게 그렇게 유용한지 공유해 보고 싶었습니다.
바쁘신가요? 빠른 팁이 필요하다면 마지막으로 건너뛰기.
보통의 Git 사용자라면(혹은 더 고급 워크플로를 깊게 파보지 않은 Jujutsu 사용자라도) 머지 커밋에는 사실 특별한 것이 전혀 없다는 사실에 놀랄 수 있습니다. 이것은 자체 규칙을 가진 어떤 특수 사례가 아닙니다. 그저 부모가 여러 개인 일반 커밋일 뿐입니다. 심지어 비어 있을 필요도 없습니다
@ myzpxsys Isaac Corbrey 12 seconds ago 634e82e2
│ (empty) (no description set)
○ mllmtkmv Isaac Corbrey 12 seconds ago git_head() 947a52fd
├─╮ (empty) Merge the things
│ ○ vqsqmtlu Isaac Corbrey 12 seconds ago f41c796e
│ │ deps: Pin quantum manifold resolver
○ │ tqqymrkn Isaac Corbrey 19 seconds ago 0426baba
├─╯ storage: Align transient cache manifolds
◆ zzzzzzzz root() 00000000
이제 이걸 전부 합쳐야겠죠!
더 놀라운 사실은 머지 커밋의 부모가 둘로 제한되지 않는다는 점입니다. 부모가 셋 이상인 머지 커밋은 비공식적으로 “octopus merge”라고 부르는데, “도대체 어느 세상에서 두 개보다 많은 브랜치를 머지하고 싶지?”라고 생각할 수도 있지만, 사실 이건 정말 강력한 개념입니다. Octopus merge는 메가머지 워크플로 전체를 떠받칩니다!
기본적으로 메가머지 워크플로에서는 브랜치의 끝점에서 직접 작업하는 일이 드뭅니다. 대신, 신경 쓰는 모든 작업 브랜치의 자식으로 octopus merge 커밋(이하 “메가머지”)을 만듭니다. 여기에는 버그 수정, 기능 브랜치, PR 리뷰를 기다리는 브랜치, 내 코드가 함께 동작해야 하는 다른 사람의 브랜치, 로컬 환경 설정 브랜치, 심지어 어떤 브랜치에도 속하지 않을 수도 있는 비공개 커밋까지 포함됩니다. 여러분이 중요하게 생각하는 모든 것 이 메가머지에 들어갑니다. 중요한 점은 메가머지는 push하지 않고, 그것을 구성하는 브랜치들만 push한다는 것입니다.
@ mnrxpywt Isaac Corbrey 25 seconds ago f1eb374e
│ (empty) (no description set)
○ wuxuwlox Isaac Corbrey 25 seconds ago git_head() c40c2d9c
├─┬─╮ (empty) megamerge
│ │ ○ ttnyuntn Isaac Corbrey 57 seconds ago 7d656676
│ │ │ storage: Align transient cache manifolds
│ ○ │ ptpvnsnx Isaac Corbrey 25 seconds ago 897d21c7
│ │ │ parser: Deobfuscate fleem tokens
│ ○ │ zwpzvxmv Isaac Corbrey 37 seconds ago 14971267
│ │ │ infra: Refactor blob allocator
│ ○ │ tqxoxrwq Isaac Corbrey 57 seconds ago 90bf43e4
│ ├─╯ io: Unjam polarity valves
○ │ moslkvzr Isaac Corbrey 50 seconds ago 753ef2e7
│ │ deps: Pin quantum manifold resolver
○ │ qupprxtz Isaac Corbrey 57 seconds ago 5332c1fd
├─╯ ui: Defrobnicate layout heuristics
○ wwtmlyss Isaac Corbrey 57 seconds ago 5804d1fd
│ test: Add hyperfrobnication suite
◆ zzzzzzzz root() 00000000
무섭죠! 머지가 너무 많습니다!
이게 좀 벅차게 들려도 괜찮습니다. 오래된 PR을 다시 꺼내 리뷰받아야 할 때 문맥 전환에 얼마나 많은 노력을 들이는지 여러분도 잘 아실 테니까요. 하지만 이 방식은 몇 가지 매우 가치 있는 장점을 가져다줍니다.
메가머지를 시작하는 것은 아주 간단합니다. 메가머지에 넣고 싶은 각 브랜치를 부모로 하는 새 커밋을 만들기만 하면 됩니다. 저는 그 커밋에 이름을 붙이고 비워 두는 편입니다. 예를 들면 이렇습니다.
jj new x y z
jj commit --message "megamerge"
메가머지 만들기. 결국 그렇게 어렵지 않습니다!
이렇게 하면 이 전체 구조의 맨 위에 빈 커밋이 하나 남습니다. 바로 여기서 작업합니다! 메가머지 커밋 위에 있는 모든 것은 WIP로 간주됩니다. 필요에 따라 쪼개도 되고, 그 메가머지 커밋을 기반으로 여러 브랜치를 만들어도 되고, 하고 싶은 대로 해도 됩니다. 여러분이 작성하는 모든 것은 메가머지 안의 모든 것의 합을 기반으로 하게 되며, 이것이 바로 우리가 원하던 것입니다!
물론 언젠가는 결과에 만족하게 되고, 이런 생각이 들 겁니다.
WIP 변경 사항을 메가머지에 어떻게 넣을지는 그것이 어디에 안착해야 하는지에 달려 있습니다. 기존 변경 사항에 들어가야 하는 수정이라면 squash 명령과 --to 플래그를 사용해 적절한 하위 커밋으로 밀어 넣을 수 있습니다. 하나의 커밋 안에 여러 커밋 분량의 변경 사항이 들어 있다면, 먼저 split으로 여러 커밋으로 나눈 뒤 squash할 수도 있고, 또는(제가 더 선호하는 방법으로) squash --interactive를 사용해 옮길 부분만 골라 인터랙티브하게 squash할 수도 있습니다.
# WIP 커밋 전체를 squash (`--from @`가 기본값)
jj squash --to x --from y
# WIP 커밋의 일부를 인터랙티브하게 squash (`--from @`가 기본값)
jj squash --to x --from y --interactive
Hunk, 널 선택하겠다!
물론 Jujutsu는 아름다운 소프트웨어답게 이것을 위한 자동화도 제공합니다! absorb 명령은 현재 커밋의 각 줄이나 hunk가 어떤 하위의 mutable 커밋에 속하는지 식별해서 자동으로 아래로 squash해 줍니다. 이걸 쓸 때마다 마법처럼 느껴집니다(아무것도 이해할 수 없는 사악한 블랙박스 흑마술 같은 종류의 마법이 아니라는 점도 좋고요). 그리고 이것이야말로 메가머지 워크플로를 아주 매끄럽게 만들어 주는 Jujutsu 핵심 기능 중 하나입니다.
# 마법처럼 변경 사항을 autosquash (`--from @`가 기본값)
jj absorb --from x
어이쿠, 정말 빨랐네요.
absorb가 항상 커밋의 모든 것을 잡아내지는 못하지만, 보통은 적어도 90%의 변경 사항은 처리해 줍니다. 나머지는 보통 쉽게 downstream으로 squash할 수 있거나, 이전 커밋과 관련 없는 변경 사항입니다.
편리하게도, 새 커밋에 속해야 하는 변경 사항이 있을 때도 그다지 복잡해지지 않습니다. 그 커밋이 제가 작업 중인 브랜치 중 하나에 속한다면, 그냥 직접 rebase하고 bookmark를 그에 맞게 옮기면 됩니다.
jj commit
jj rebase --revision x --after y --before megamerge
jj bookmark move --from y --to x
이 rebase를 좀 더 잘 이해할 수 있도록 풀어서 보겠습니다.
# 이제 커밋 몇 개를 이리저리 옮겨 봅시다!
jj rebase
# WIP 커밋 x를 옮겨서...
--revision x
# y 뒤에 오게 하고(예: trunk())...
--after y
# 메가머지의 부모가 되게 합니다.
--before megamerge
약간의 로켓 수술이지만, 가끔은 괜찮죠.
완전히 새로운 기능 작업을 시작했거나 별개의 버그를 고치게 되었다면 더 단순합니다! 몇 가지 alias를 사용하면 새 변경 사항을 메가머지에 아주 쉽게 포함시킬 수 있습니다.2
그리고 templating language를 사용해 Jujutsu가 터미널에 로그를 출력하는 방식을 바꿀 수 있는 template alias도 있고, fileset language를 사용해 revset alias와 비슷하게 동작하지만 revision이 아니라 파일에 대해 동작하는 fileset alias도 있습니다.
[revset-aliases]
# `to`에 가장 가까운 머지 커밋을 반환
"closest_merge(to)" = "heads(::to & merges())"
[aliases]
# 주어진 revset을 메가머지 아래의 새 브랜치로 삽입
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]
closest_merge(to)가 실제로 무엇을 하는지 간단히 설명해 보겠습니다.
heads( # 다음 집합 안에서 위상적으로 가장 끝에 있는 커밋만 반환...
::to # `to`의 조상인 모든 커밋의 집합 중에서...
& merges()) # ...머지 커밋이기도 한 것들.
이 revset alias를 사용하면 stack으로 원하는 어떤 revset이든 대상으로 잡아 trunk()(주 개발 브랜치)와 메가머지 커밋 사이에 삽입할 수 있습니다.
jj stack x::y
와, 이거 깔끔하네요!
이건 여러 개의 변경 스택을 병렬로 포함하고 싶을 때 특히 더 유용합니다. 하나뿐이라면, 메가머지 뒤의 전체 변경 스택을 통째로 가져오는 다른 alias를 씁니다.
[aliases]
stage = ["stack", "closest_merge(@).. ~ empty()"]
closest_merge(@).. # 작업 복사본에 가장 가까운 머지 커밋의
# 자손들을 반환하되...
~ empty() # ...비어 있는 커밋은 제외.
이건 입력도 필요 없습니다! 커밋만 준비해 두고 그냥 stage하면 됩니다.
jj stage
잠깐, 뭐라고요? 이게 된다고요?
이 메가머지 퍼즐의 마지막 빠진 조각은 (안타깝게도) 다른 사람들 이라는 현실을 다루는 것입니다.
아주 좋은 질문이고, 저도 이걸 일반적인 방식으로 해결하려고 몇 달을 보냈습니다. Jujutsu에는 작업 트리 전체를 메인 브랜치 위로 rebase하는 아주 쉬운 방법이 있습니다.
jj rebase --onto trunk()
좋네요.
하지만 이 방법은 작업 트리 전체가 내 변경 사항일 때만 통합니다. 내가 소유하지 않은 커밋(예: 추적되지 않는 bookmark나 다른 사람의 브랜치)을 참조하려고 하면 Jujutsu는 그것들이 다시 쓰이는 것을 막기 위해 일찍 멈춥니다.3
음, 그렇게 좋지만은 않네요. 그럼 이건 어떻게 하죠?
그럼 실제로 우리가 제어할 수 있는 커밋만 rebase해서 해결해 봅시다. 저는 이 문제로 한동안 애를 먹었지만, 다행히 Jujutsu 커뮤니티는 정말 훌륭합니다. 이 멋진 revset을 생각해 낸 Stephen Jennings에게 찬사를 보냅니다.
[aliases]
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & mutable()"]
roots( # 가장 위쪽에 있는 upstream 커밋들을 가져오되...
trunk()..) # ...::trunk()의 모든 자손 집합 안에서...
& mutable() # ...수정이 허용된 것만 반환.
작업 트리 전체를 rebase하려고 시도하는 대신(jj rebase --onto trunk()가 하려는 것처럼), 이 alias는 실제로 우리가 옮길 수 있는 커밋만 대상으로 잡습니다. 그래서 우리가 제어하지 않는 브랜치나 다른 사람 브랜치 위에 쌓인 작업은 그대로 남겨 둡니다. 무려 아홉 갈래에 기여자까지 뒤섞인 괴물 같은 메가머지에서도 아직 저를 실망시킨 적이 없습니다! (빠르게 다섯 번 말해 보세요.)
자, 됐습니다. 이게 훨씬 낫네요!
Jujutsu 메가머지는 정말 멋지고, 서로 다른 여러 작업 흐름을 동시에 진행할 수 있게 해 줍니다. 어떻게 동작하는지 깊이 있게 이해하려면 글 전체를 읽어 보세요. 아주 손에 익는 설정을 원한다면 jj config edit --user로 다음 내용을 config에 추가하세요.
[revset-aliases]
"closest_merge(to)" = "heads(::to & merges())"
[aliases]
# 특정 rev를 포함하려면 `jj stack <revset>`
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]
# 메가머지 뒤의 전체 스택을 포함하려면 `jj stage`
stage = ["stack", "closest_merge(@).. ~ empty()"]
# 변경 사항을 `trunk()` 위로 rebase하려면 `jj restack`
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & mutable()"]
기존 커밋에 새 변경 사항을 넣으려면 absorb와/또는 squash --interactive를 사용하고, 메가머지 아래에 새 커밋을 만들려면 commit과 rebase를 사용하고, 브랜치 전체를 메가머지 안으로 옮기려면 commit과 함께 stack 또는 stage를 사용하세요.4
# 기존 커밋에 속하는 변경 사항
jj absorb
jj squash --to x --interactive
# 새 커밋에 속하는 변경 사항
jj rebase --revision y --after x
# 메가머지 위에 있는 무엇이든 메가머지 안으로 스택하기
jj stage
# 특정 revset을 메가머지 안으로 스택하기
jj stack w::z
메가머지는 원격 저장소로 push하기 위한 것이 아니라는 점을 기억하세요. 이것은 전체 그림을 스스로 보기 위한 편리한 방법일 뿐입니다. 브랜치는 여전히 평소처럼 개별적으로 publish하는 것이 좋습니다.
저는 늘 이런 방식으로 살고 있고, 여러분도 그럴 수 있습니다.
메가머지는 모든 사람의 취향에 맞는 것은 아닐 수 있습니다. 제 작업 트리를 보여준 뒤 경악한 표정을 몇 번 본 적도 있으니까요. 하지만 한 번 써 보면, 거의 아무런 노력 없이 작업 사이를 오갈 수 있게 해 준다는 사실을 알게 될 가능성이 큽니다. 한번 시도해 보세요!
Commit ID: b976b2a9c6ebbaada7fcd9d112a8390f2cb75b54
Change ID: tqxoxrwqqqtmxvywmzmspstupqqkskqk
Author : Isaac Corbrey <isaac@isaaccorbrey.com> (28 minutes ago)
Committer: Isaac Corbrey <isaac@isaaccorbrey.com> (24 minutes ago)
Parent : ttnyuntn storage: Align transient cache manifolds
Parent : qupprxtz ui: Defrobnicate layout heuristics
io: Unjam polarity valves
Added regular file two.txt:
1: # Sphinx of black quartz, judge my vow
부글부글, 끓어라 가마솥, 수고와 소동이여.
확실히 본론과는 조금 벗어나지만, 언급할 필요가 있다고 느꼈습니다. ↩
Alias는 Jujutsu에서 아주 강력한 부분입니다. 살펴봐야 할 유형은 두 가지입니다. revset alias는 revset language를 사용해 하나 이상의 커밋을 반환하는 사용자 정의 함수를 만들 수 있게 해 주고, command alias는 Jujutsu의 기본 기능을 확장하고 자신만의 기능을 추가할 수 있게 해 줍니다. ↩
Jujutsu에는 mutable 커밋과 immutable 커밋이라는 개념이 있는데, 이는 기본적으로 평소에 어떤 커밋을 수정할 수 있는지를 결정합니다. --ignore-immutable로 이 제약을 무시할 수 있으므로 대체로 일종의 lint에 가깝지만, 문제를 피하게 해 주는 데는 꽤 유용합니다. mutable() 및 immutable() alias를 사용하면 mutable 커밋과 immutable 커밋만 각각 선택할 수 있습니다. ↩
restack이 원하는 방식으로 딱 맞게 동작하지 않는다면, Austin Seipp의 이 config를 참고해 반영해 보세요. 제 기본 설정은 저장소의 모든 mutable 커밋을 restack하는데, 과거의 mutable 브랜치가 많이 남아 있고 아직 정리할 시간이 없을 때는 좋지 않게 동작합니다.
[revset-aliases]
'stack()' = 'stack(@)'
'stack(x)' = 'stack(x, 2)'
'stack(x, n)' = 'ancestors(reachable(x, mutable()), n)'
[aliases]
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & stack()"]
팁 고마워요 Cole! ↩