Git에 익숙한 독자를 위한 Jujutsu의 기본 사용법, 로그와 revset, 충돌 해결, 작업 로그, 커밋 간 내용 이동을 다루는 튜토리얼입니다.
이 글은 독자가 Git에 익숙하다고 가정합니다.
아직 하지 않았다면, Jujutsu를 설치하고 구성했는지 확인하세요.
jj를 사용해 GitHub의 Hello-World 저장소를 복제하는 것부터 시작해 보겠습니다:
# Note the "git" before "clone" (there is no support for cloning native jj
# repos yet)
$ jj git clone https://github.com/octocat/Hello-World
Fetching into new repo in "/tmp/tmp.O1DWMiaKd4/Hello-World"
Working copy now at: d7439b06fbef (no description set)
Added 1 files, modified 0 files, removed 0 files
$ cd Hello-World
이제 jj st (jj status의 줄임말)를 실행하면 다음과 비슷한 결과가 나옵니다:
$ jj st
Parent commit: 7fd1a60b01f9 Merge pull request #6 from Spaceghost/patch-1
Working copy : d7439b06fbef (no description set)
The working copy is clean
위 출력에서 작업 복사본이 커밋 ID(예시에서는 d7439b06fbef)를 가진 실제 커밋임을 알 수 있습니다. 작업 복사본에서 변경을 만들면, 다음 jj 명령에서 작업 복사본 커밋이 자동으로 amend됩니다.
이제 저장소의 README 파일을 편집해서 "Hello" 대신 "Goodbye"라고 쓰고 싶다고 해 봅시다. 무엇을 작업 중인지 잊지 않도록 먼저 변경을 설명하는 커밋 메시지를 추가해 보겠습니다:
# This will bring up $EDITOR (or `pico` or `Notepad` by default). Enter
# something like "Say goodbye" in the editor and then save the file and close
# the editor.
$ jj describe
Working copy now at: e427edcfd0ba Say goodbye
이제 README를 변경합니다:
# Adjust as necessary for compatibility with your flavor of `sed`
$ sed -i 's/Hello/Goodbye/' README
$ jj st
Parent commit: 7fd1a60b01f9 Merge pull request #6 from Spaceghost/patch-1
Working copy : 5d39e19dac36 Say goodbye
Working copy changes:
M README
git add를 사용할 때처럼 변경을 추가하라고 Jujutsu에 알려줄 필요가 없었다는 점에 주목하세요. 사실 새 파일을 추가하거나 기존 파일을 삭제할 때도 따로 알려줄 필요가 없습니다. 경로 추적을 해제하려면 .gitignore에 추가한 뒤 jj untrack <path>를 실행하면 됩니다.
차이를 보려면 jj diff를 실행하세요:
$ jj diff --git # Feel free to skip the `--git` flag
diff --git a/README b/README
index 980a0d5f19...1ce3f81130 100644
--- a/README
+++ b/README
@@ -1,1 +1,1 @@
-Hello World!
+Goodbye World!
Jujutsu의 diff 형식은 현재 기본적으로 차이를 인라인 색상으로 표시합니다(git diff --color-words와 비슷함). 그래서 이 튜토리얼에서는 diff를 읽기 쉽게 하기 위해 위에서 --git을 사용했습니다.
아마 눈치챘겠지만, 설명을 편집했을 때와 README를 편집했을 때 모두 작업 복사본 커밋의 ID가 바뀌었습니다. 하지만 부모 커밋은 그대로였습니다. 작업 복사본 커밋을 바꿀 때마다 이전 버전이 amend됩니다. 그렇다면 현재 변경의 amend를 끝내고 새로운 변경 작업을 시작하고 싶을 때는 어떻게 Jujutsu에 알릴까요? 그때 쓰는 것이 jj new입니다. 이 명령은 현재 작업 복사본 커밋 위에 새 커밋을 만듭니다. 새 커밋은 작업 복사본의 변경을 위한 것입니다. 다른 VCS에서 온 사용자가 익숙하게 느끼도록 jj checkout/co 명령도 제공되는데, 이는 사실상 jj new의 동의어입니다(jj new에는 대상 위치도 지정할 수 있습니다).
이제 이 변경이 끝났다고 가정하고, 새 변경을 만들어 보겠습니다:
$ jj new
Working copy now at: aef4df99ea11 (no description set)
$ jj st
Parent commit: 5d39e19dac36 Say goodbye
Working copy : aef4df99ea11 (no description set)
The working copy is clean
나중에 추가 변경이 필요하다는 것을 깨달았다면, 작업 복사본에서 수정한 뒤 jj squash를 실행할 수 있습니다. 이 명령은 주어진 커밋의 변경을 그 부모 커밋으로 squash합니다. 대부분의 명령처럼 기본적으로 작업 복사본 커밋에 대해 동작합니다. 작업 복사본 커밋에서 실행하면 git commit --amend와 매우 비슷하게 동작하며, 실제로 jj amend는 jj squash의 별칭입니다.
또는 jj edit <commit>를 사용해 작업 복사본에서 커밋 편집을 다시 시작할 수도 있습니다. 그러면 작업 복사본에서 이루어지는 추가 변경은 그 커밋을 amend하게 됩니다. checkout 후 squash할지, edit를 사용할지는 보통 변경이 얼마나 마무리되었는지에 따라 달라집니다. 변경이 거의 끝난 상태라면 jj checkout을 사용해서 jj squash를 실행하기 전에 jj diff로 조정 내용을 쉽게 검토하는 것이 합리적입니다.
아마 git log에는 익숙할 것입니다. Jujutsu도 jj log 명령으로 매우 비슷한 기능을 제공합니다:
$ jj log
@ mpqrykypylvy martinvonz@google.com 2023-02-12 15:00:22.000 -08:00 aef4df99ea11
│ (empty) (no description set)
◉ kntqzsqtnspv martinvonz@google.com 2023-02-12 14:56:59.000 -08:00 5d39e19dac36
│ Say goodbye
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
@는 작업 복사본 커밋을 나타냅니다. 한 줄의 첫 번째 ID(예: 위의 "mpqrykypylvy")는 "change ID"로, 커밋이 다시 쓰여도 그 커밋을 따라가는 ID입니다(Gerrit의 Change-Id와 비슷함). 두 번째 ID는 커밋 ID로, 커밋을 다시 쓰면 바뀝니다. 개정을 인수로 받는 명령에는 두 ID 중 어느 쪽이든 전달할 수 있습니다. 일반적으로는 커밋이 다시 쓰여도 유지되는 change ID를 선호할 것입니다.
기본적으로 jj log는 로컬 커밋을 나열하고, 문맥을 위해 몇 개의 원격 커밋을 함께 보여줍니다. ~는 그래프에 포함되지 않은 부모를 그 커밋이 가지고 있음을 나타냅니다. --revisions/-r 플래그를 사용하면 나열할 개정 집합을 다르게 선택할 수 있습니다. 이 플래그는 "revset"을 받는데, 이는 개정을 지정하기 위한 간단한 언어의 표현식입니다. 예를 들어 @는 작업 복사본 커밋을, root()는 루트 커밋을, branches()는 브랜치가 가리키는 모든 커밋을 의미합니다. 표현식은 합집합 |, 교집합 &, 차집합 ~로 결합할 수 있습니다. 예를 들면 다음과 같습니다:
$ jj log -r '@ | root() | branches()'
@ mpqrykypylvy martinvonz@google.com 2023-02-12 15:00:22.000 -08:00 aef4df99ea11
╷ (empty) (no description set)
╷ ◉ kowxouwzwxmv octocat@nowhere.com 2014-06-10 15:22:26.000 -07:00 test b3cbd5bbd7e8
╭─╯ Create CONTRIBUTING.md
│ ◉ tpstlustrvsn support+octocat@github.com 2018-05-10 12:55:19.000 -05:00 octocat-patch-1 b1b3f9723831
├─╯ sentence case
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
╷ (empty) Merge pull request #6 from Spaceghost/patch-1
◉ zzzzzzzzzzzz 1970-01-01 00:00:00.000 +00:00 000000000000
(empty) (no description set)
000000000000 커밋(change ID는 zzzzzzzzzzzz)은 "루트 커밋"이라고 불리는 가상 커밋입니다. 모든 저장소의 루트 커밋이며, revset의 root() 함수가 이것과 일치합니다.
부모(foo-), 자식(foo+), 조상(::foo), 자손(foo::), DAG 범위(foo::bar, git log --ancestry-path와 비슷함), 범위(foo..bar, Git과 동일함)를 가져오는 연산자도 있습니다. 모든 revset 연산자와 함수는 revset 문서를 참고하세요.
이제 Jujutsu가 병합 충돌을 어떻게 다루는지 살펴보겠습니다. 먼저 몇 개의 커밋을 만들어 보겠습니다. 변경 설명(커밋 메시지)을 바로 설정하기 위해 jj new에 --message/-m 옵션을 사용합니다.
# Start creating a chain of commits off of the `master` branch
$ jj new master -m A; echo a > file1
Working copy now at: 00a2aeed556a A
Added 0 files, modified 1 files, removed 0 files
$ jj new -m B1; echo b1 > file1
Working copy now at: 967d9f9fd288 B1
$ jj new -m B2; echo b2 > file1
Working copy now at: 8ebeaffa332b B2
$ jj new -m C; echo c > file2
Working copy now at: 62a3c6d315cd C
$ jj log
@ qzvqqupxlkot martinvonz@google.com 2023-02-12 15:07:41.946 -08:00 2370ddf3fa39
│ C
◉ puqltuttrvzp martinvonz@google.com 2023-02-12 15:07:33.000 -08:00 daa6ffd5a09a
│ B2
◉ ovknlmrokpkl martinvonz@google.com 2023-02-12 15:07:24.000 -08:00 7d7c6e6bd0b4
│ B1
◉ nuvyytnqlquo martinvonz@google.com 2023-02-12 15:07:05.000 -08:00 5dda2f097aa9
│ A
│ ◉ kntqzsqtnspv martinvonz@google.com 2023-02-12 14:56:59.000 -08:00 5d39e19dac36
├─╯ Say goodbye
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
이제 몇 개의 커밋이 생겼고, A, B1, B2는 같은 파일을 수정하는 반면 C는 다른 파일을 수정합니다. 이제 B2를 A 바로 위로 rebase해 보겠습니다. B2의 change ID에 --source/-s 옵션을, A에는 --destination/-d 옵션을 사용합니다.
$ jj rebase -s puqltuttrvzp -d nuvyytnqlquo
Rebased 2 commits
Working copy now at: 1978b53430cd C
Added 0 files, modified 1 files, removed 0 files
$ jj log
@ qzvqqupxlkot martinvonz@google.com 2023-02-12 15:08:33.000 -08:00 1978b53430cd conflict
│ C
◉ puqltuttrvzp martinvonz@google.com 2023-02-12 15:08:33.000 -08:00 f7fb5943ee41 conflict
│ B2
│ ◉ ovknlmrokpkl martinvonz@google.com 2023-02-12 15:07:24.000 -08:00 7d7c6e6bd0b4
├─╯ B1
◉ nuvyytnqlquo martinvonz@google.com 2023-02-12 15:07:05.000 -08:00 5dda2f097aa9
│ A
│ ◉ kntqzsqtnspv martinvonz@google.com 2023-02-12 14:56:59.000 -08:00 5d39e19dac36
├─╯ Say goodbye
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
여기에는 주목할 만한 점이 몇 가지 있습니다. 첫째, jj rebase 명령이 "Rebased 2 commits"라고 말했습니다. 이는 -s 옵션으로 B2 커밋을 rebase하라고 했고, 그 경우 자손도 함께 rebase되기 때문입니다(이 경우 C 커밋). 둘째, B2가 B1과 같은 파일(그리고 같은 단어)을 수정했기 때문에, 출력에 나타나듯 rebase 결과 충돌이 발생했습니다. 셋째, 충돌이 생겼다고 해서 rebase가 성공적으로 완료되는 것이 막히지 않았고, C가 그 위로 rebase되는 것도 막히지 않았습니다.
이제 B2의 충돌을 해결해 보겠습니다. B2 위에 새 커밋을 만들어 해결하겠습니다. 충돌을 해결한 뒤에는 그 해결 내용을 충돌이 있던 B2에 squash할 것입니다. 예를 들면 다음과 같습니다:
$ jj new puqltuttrvzp # Replace the ID by what you have for B2
Working copy now at: c7068d1c23fd (no description set)
Added 0 files, modified 0 files, removed 1 files
$ jj st
Parent commit: f7fb5943ee41 B2
Working copy : c7068d1c23fd (no description set)
The working copy is clean
There are unresolved conflicts at these paths:
file1 2-sided conflict
$ cat file1
<<<<<<<
%%%%%%%
-b1
+a
+++++++
b2
>>>>>>>
$ echo resolved > file1
$ jj squash
Rebased 1 descendant commits
Working copy now at: e3c279cc2043 (no description set)
$ jj log
@ ntxxqymrlvxu martinvonz@google.com 2023-02-12 19:34:09.000 -08:00 e3c279cc2043
│ (empty) (no description set)
│ ◉ qzvqqupxlkot martinvonz@google.com 2023-02-12 19:34:09.000 -08:00 b9da9d28b26b
├─╯ C
◉ puqltuttrvzp martinvonz@google.com 2023-02-12 19:34:09.000 -08:00 2c7a658e2586
│ B2
│ ◉ ovknlmrokpkl martinvonz@google.com 2023-02-12 15:07:24.000 -08:00 7d7c6e6bd0b4
├─╯ B1
◉ nuvyytnqlquo martinvonz@google.com 2023-02-12 15:07:05.000 -08:00 5dda2f097aa9
│ A
│ ◉ kntqzsqtnspv martinvonz@google.com 2023-02-12 14:56:59.000 -08:00 5d39e19dac36
├─╯ Say goodbye
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
커밋 C가 해결된 B2 위로 자동으로 rebase되었고, C 역시 해결된 상태라는 점에 주목하세요(서로 다른 파일만 수정했기 때문입니다).
그런데 이제 B1을 없애고 싶다면 다음을 실행하면 됩니다.
jj abandon
ovknlmrokpkl
. 그러면 해당 커밋은 log 출력에서 숨겨지고, 그 자손들은 부모로 rebase됩니다.
Jujutsu는 저장소에 대해 수행한 모든 변경의 기록을 "작업 로그"라고 부르는 곳에 보관합니다. 이것과 상호작용하려면 jj op (jj operation의 줄임말) 명령군을 사용하세요. 작업 목록을 보려면 jj op log를 사용합니다:
$ jj op log
@ d3b77addea49 martinvonz@vonz.svl.corp.google.com 2023-02-12 19:34:09.549 -08:00 - 2023-02-12 19:34:09.552 -08:00
│ squash commit 63874fe6c4fba405ffc38b0dd926f03b715cf7ef
│ args: jj squash
◉ 6fc1873c1180 martinvonz@vonz.svl.corp.google.com 2023-02-12 19:34:09.548 -08:00 - 2023-02-12 19:34:09.549 -08:00
│ snapshot working copy
◉ ed91f7bcc1fb martinvonz@vonz.svl.corp.google.com 2023-02-12 19:32:46.007 -08:00 - 2023-02-12 19:32:46.008 -08:00
│ new empty commit
│ args: jj new puqltuttrvzp
◉ 367400773f87 martinvonz@vonz.svl.corp.google.com 2023-02-12 15:08:33.917 -08:00 - 2023-02-12 15:08:33.920 -08:00
│ rebase commit daa6ffd5a09a8a7d09a65796194e69b7ed0a566d and descendants
│ args: jj rebase -s puqltuttrvzp -d nuvyytnqlquo
[many more lines]
가장 유용한 명령은 jj undo (jj op undo의 별칭)로, 어떤 작업이든 되돌릴 수 있습니다. 기본적으로 가장 최근 작업을 되돌립니다. 한번 해 봅시다:
$ jj undo
Working copy now at: 63874fe6c4fb (no description set)
$ jj log
@ zxoosnnpvvpn martinvonz@google.com 2023-02-12 19:34:09.000 -08:00 63874fe6c4fb
│ (no description set)
│ ◉ qzvqqupxlkot martinvonz@google.com 2023-02-12 15:08:33.000 -08:00 1978b53430cd conflict
├─╯ C
◉ puqltuttrvzp martinvonz@google.com 2023-02-12 15:08:33.000 -08:00 f7fb5943ee41 conflict
│ B2
│ ◉ ovknlmrokpkl martinvonz@google.com 2023-02-12 15:07:24.000 -08:00 7d7c6e6bd0b4
├─╯ B1
◉ nuvyytnqlquo martinvonz@google.com 2023-02-12 15:07:05.000 -08:00 5dda2f097aa9
│ A
│ ◉ kntqzsqtnspv martinvonz@google.com 2023-02-12 14:56:59.000 -08:00 5d39e19dac36
├─╯ Say goodbye
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
아마 보이겠지만, 이것은 앞서 충돌 해결을 커밋 B2에 squash하는 데 사용했던 jj squash 호출을 되돌린 것입니다. 작업 복사본도 함께 업데이트되었다는 점에 주목하세요.
이전 작업 직후의 저장소 상태를 보는 것도 가능합니다. 예를 들어 jj rebase 작업 직후의 jj log 출력을 보고 싶다면 jj log --at-op=367400773f87를 시도해 보세요. 다만 해시는 자신의 jj op log 출력에 나온 것을 사용해야 합니다.
앞에서 jj squash가 두 커밋의 변경을 하나로 합칠 수 있다는 것을 이미 보았습니다. 기존 커밋의 내용을 바꾸는 다른 명령도 여럿 있습니다. 이 명령들은 meld가 설치되어 있다고 가정합니다. 터미널 기반 diff 편집기를 선호한다면, 대신 scm-diff-editor를 구성할 수 있습니다.
이 명령들을 시험하려면 조금 더 복잡한 내용이 필요하므로, 몇 개의 커밋을 더 만들어 보겠습니다:
$ jj new master -m abc; printf 'a\nb\nc\n' > file
Working copy now at: f94e49cf2547 abc
Added 0 files, modified 0 files, removed 1 files
$ jj new -m ABC; printf 'A\nB\nc\n' > file
Working copy now at: 6f30cd1fb351 ABC
$ jj new -m ABCD; printf 'A\nB\nC\nD\n' > file
Working copy now at: a67491542e10 ABCD
$ jj log -r master::@
@ mrxqplykzpkw martinvonz@google.com 2023-02-12 19:38:21.000 -08:00 b98c607bf87f
│ ABCD
◉ kwtuwqnmqyqp martinvonz@google.com 2023-02-12 19:38:12.000 -08:00 30aecc0871ea
│ ABC
◉ ztqrpvnwqqnq martinvonz@google.com 2023-02-12 19:38:03.000 -08:00 510022615871
│ abc
◉ orrkosyozysx octocat@nowhere.com 2012-03-06 15:06:50.000 -08:00 master 7fd1a60b01f9
│ (empty) Merge pull request #6 from Spaceghost/patch-1
~
두 번째 커밋에서 다른 글자들을 대문자로 바꿀 때 "c"를 대문자로 바꾸는 것을 "깜빡한" 셈입니다. 그리고 세 번째 커밋에서 "D"를 추가하면서 그 문제를 고쳤습니다. 하지만 "c"를 대문자로 바꾸는 변경을 두 번째 커밋으로 옮기는 편이 더 깔끔합니다. 이를 위해 세 번째 커밋에서 --interactive/-i 옵션과 함께 jj squash를 실행할 수 있습니다. jj squash는 한 커밋의 모든 변경을 그 부모로 옮긴다는 점을 기억하세요. jj squash -i는 그중 일부만 부모로 옮깁니다. 이제 직접 해 보세요:
$ jj squash -i
Using default editor 'meld'; you can change this by setting ui.diff-editor
Working copy now at: 52a6c7fda1e3 ABCD
그러면 "ABCD" 커밋의 변경 diff를 보여주는 Meld가 열립니다. diff의 오른쪽을 수정해서 "D" 줄을 제거함으로써 "ABC"에서 원하는 최종 상태가 되도록 만드세요. 그다음 변경을 저장하고 Meld를 닫습니다. 두 번째 커밋의 diff를 보면 이제 세 줄 모두가 대문자로 바뀐 것을 볼 수 있습니다:
$ jj diff -r @-
Modified regular file file:
1 1: aA
2 2: bB
3 3: cC
자식 변경(이 경우 "ABCD")은 jj squash 명령 뒤에도 동일한 내용 상태 를 유지합니다. 즉, 같은 단어를 건드리는 경우라도 원하는 변경을 얼마든지 부모 변경으로 옮길 수 있으며, 충돌이 발생하지 않습니다.
기존 커밋의 내용을 바꾸는 마지막 명령 하나를 더 살펴보겠습니다. 그 명령은 jj diffedit이며, 체크아웃하지 않고도 커밋의 내용을 편집할 수 있게 해 줍니다.
$ jj diffedit -r @-
Using default editor 'meld'; you can change this by setting ui.diff-editor
Created 70985eaa924f ABC
Rebased 1 descendant commits
Working copy now at: 1c72cd50525d ABCD
Added 0 files, modified 1 files, removed 0 files
Meld가 시작되면, 예를 들어 첫 번째 줄에 무언가를 추가하는 식으로 오른쪽을 편집하세요. 그런 다음 변경을 저장하고 Meld를 닫습니다. 이제 다시 jj diff -r @-로 다시 작성된 커밋을 확인할 수 있고, 첫 번째 줄에 추가한 내용이 보여야 합니다. 커밋의 내용 상태를 바꾸지 않았던 jj squash -i와 달리, jj diffedit는 (보통) 다른 상태를 만들기 때문에 자손 커밋에 충돌이 생길 수 있습니다.
기존 커밋의 내용을 다시 쓰는 다른 명령으로는 jj split,
jj
unsquash -i
및 jj move -i가 있습니다. 이제 jj squash -i와
jj
diffedit
가 어떻게 동작하는지 보았으니, diff에 있는 안내의 도움을 받아 그것들도 어떻게 작동하는지 아마 파악할 수 있을 것입니다.