Git
Chapters ▾ 2nd Edition

7.11 Git 도구 - 서브모듈

서브모듈

프로젝트를 수행하다 보면 다른 프로젝트를 함께 사용해야 하는 경우가 종종 있다. 함께 사용할 다른 프로젝트는 외부에서 개발한 라이브러리라던가 내부 여러 프로젝트에서 공통으로 사용할 라이브러리일 수 있다. 이런 상황에서 자주 생기는 이슈는 두 프로젝트를 서로 별개로 다루면서도 그 중 하나를 다른 하나 안에서 사용할 수 있어야 한다는 것이다.

Atom 피드를 제공하는 웹사이트를 만드는 것을 예로 들어보자. Atom 피드를 생성하는 코드는 직접 작성하지 않고 라이브러리를 가져다 쓰기로 한다. 라이브러리를 사용하려면 CPAN이나 Ruby gem 같은 라이브러리 관리 도구를 사용하여 Shared 라이브러리 형태로 쓰거나 직접 라이브러리의 소스코드를 프로젝트로 복사해서 사용할 수 있다. 우선 Shared 라이브러리를 사용하기에는 문제가 있다. 프로젝트를 사용하는 모든 환경에 라이브러리가 설치되어 있어야 하고 라이브러리를 프로젝트에 맞게 약간 수정해서 사용하고 배포하기가 어렵다. 또한, 라이브러리 소스코드를 직접 프로젝트에 포함시키는 경우에는 라이브러리 Upstream 코드가 업데이트됐을 때 Merge 하기가 어렵다.

Git의 서브모듈은 이런 문제를 다루는 도구다. Git 저장소 안에 다른 Git 저장소를 디렉토리로 분리해 넣는 것이 서브모듈이다. 다른 독립된 Git 저장소를 Clone 해서 내 Git 저장소 안에 포함할 수 있으며 각 저장소의 커밋은 독립적으로 관리한다.

서브모듈 시작하기

예제로 하위 프로젝트 여러 개를 가지는 프로젝트를 하나 만들어 서브모듈의 기능을 살펴보자.

작업할 Git 저장소에 미리 준비된 리모트 Git 저장소를 서브모듈로 추가해보자. 서브모듈을 추가하는 명령으로 git submodule add 뒤에 추가할 저장소의 URL을 붙여준다. 이 URL은 절대경로도 되고 상대경로도 된다. 예제로 “DbConnector” 라는 라이브러리를 추가한다.

$ git submodule add https://github.com/chaconinc/DbConnector
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.

기본적으로 서브모듈은 프로젝트 저장소의 이름으로 디렉토리를 만든다. 예제에서는 “DbConnector” 라는 이름으로 만든다. 명령의 마지막에 원하는 이름을 넣어 다른 디렉토리 이름으로 서브모듈을 추가할 수도 있다.

서브보듈을 추가하고 난 후 git status 명령을 실행하면 몇 가지 정보를 알 수 있다.

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   .gitmodules
    new file:   DbConnector

우선 .gitmodules 파일이 만들어졌다. 이 파일은 서브디렉토리와 하위 프로젝트 URL의 매핑 정보를 담은 설정파일이다.

[submodule "DbConnector"]
    path = DbConnector
    url = https://github.com/chaconinc/DbConnector

서브모듈 개수만큼 이 항목이 생긴다. 이 파일도 .gitignore 파일처럼 버전을 관리한다. 다른 파일처럼 Push 하고 Pull 한다. 이 프로젝트를 Clone 하는 사람은 .gitmodules 파일을 보고 어떤 서브모듈 프로젝트가 있는지 알 수 있다.

Note
gitmodules 파일에 있는 URL은 조건에 맞는 사람이면 누구든지 Clone 하고 Fetch 할 수 있도록 접근할 수 있어야 한다.

예를 들어 다른 사람이 Pull을 하는 URL과 라이브러리의 작업을 Push 하는 URL이 서로 다른 상황이라면 Pull URL이 모든 사람에게 접근 가능한 URL이어야 한다. 이러면 서브모듈 URL 설정을 덮어쓰기 해서 사용할 수 있는데 git config submodule.DbConnector.url PRIVATE_URL 명령으로 다른 사람과는 다른 서브모듈 URL을 사용할 수 있다. URL을 상대경로로 적을 수 있으면 상대경로를 사용하는 것이 낫다.

.gitmodules 은 살펴봤고 이제 프로젝트 폴더에 대해 살펴보자. git diff 명령을 실행시키면 흥미로운 점을 발견할 수 있다.

$ git diff --cached DbConnector
diff --git a/DbConnector b/DbConnector
new file mode 160000
index 0000000..c3f01dc
--- /dev/null
+++ b/DbConnector
@@ -0,0 +1 @@
+Subproject commit c3f01dc8862123d317dd46284b05b6892c7b29bc

Git은 DbConnector 디렉토리를 서브모듈로 취급하기 때문에 해당 디렉토리 아래의 파일 수정사항을 직접 추적하지 않는다. 대신 서브모듈 디렉토리를 통째로 특별한 커밋으로 취급한다.

git diff--submodule 옵션을 더하면 서브모듈에 대해 더 자세히 나온다.

$ git diff --cached --submodule
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..71fc376
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "DbConnector"]
+       path = DbConnector
+       url = https://github.com/chaconinc/DbConnector
Submodule DbConnector 0000000...c3f01dc (new submodule)

이제 하위 프로젝트를 포함한 커밋을 생성하면 아래와 같은 결과를 확인할 수 있다.

$ git commit -am 'added DbConnector module'
[master fb9093c] added DbConnector module
 2 files changed, 4 insertions(+)
 create mode 100644 .gitmodules
 create mode 160000 DbConnector

DbConnector 디렉토리의 모드는 `160000`이다. Git에게 있어 160000 모드는 일반적인 파일이나 디렉토리가 아니라 특별하다는 의미다.

끝으로, Push 한다.

$ git push origin master

서브모듈 포함한 프로젝트 Clone

서브모듈을 포함하는 프로젝트를 Clone 하는 예제를 살펴본다. 이런 프로젝트를 Clone 하면 기본적으로 서브모듈 디렉토리는 빈 디렉토리이다.

$ git clone https://github.com/chaconinc/MainProject
Cloning into 'MainProject'...
remote: Counting objects: 14, done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 14 (delta 1), reused 13 (delta 0)
Unpacking objects: 100% (14/14), done.
Checking connectivity... done.
$ cd MainProject
$ ls -la
total 16
drwxr-xr-x   9 schacon  staff  306 Sep 17 15:21 .
drwxr-xr-x   7 schacon  staff  238 Sep 17 15:21 ..
drwxr-xr-x  13 schacon  staff  442 Sep 17 15:21 .git
-rw-r--r--   1 schacon  staff   92 Sep 17 15:21 .gitmodules
drwxr-xr-x   2 schacon  staff   68 Sep 17 15:21 DbConnector
-rw-r--r--   1 schacon  staff  756 Sep 17 15:21 Makefile
drwxr-xr-x   3 schacon  staff  102 Sep 17 15:21 includes
drwxr-xr-x   4 schacon  staff  136 Sep 17 15:21 scripts
drwxr-xr-x   4 schacon  staff  136 Sep 17 15:21 src
$ cd DbConnector/
$ ls
$

분명히 DbConnector 디렉토리는 있지만 비어 있다. 서브모듈에 관련된 두 명령을 실행해야 완전히 Clone 과정이 끝난다. 먼저 git submodule init 명령을 실행하면 서브모듈 정보를 기반으로 로컬 환경설정 파일이 준비된다. 이후 git submodule update 명령으로 서브모듈의 리모트 저장소에서 데이터를 가져오고 서브모듈을 포함한 프로젝트의 현재 스냅샷에서 Checkout 해야 할 커밋 정보를 가져와서 서브모듈 프로젝트에 대한 Checkout을 한다.

$ git submodule init
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) registered for path 'DbConnector'
$ git submodule update
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.
Submodule path 'DbConnector': checked out 'c3f01dc8862123d317dd46284b05b6892c7b29bc'

DbConnector 디렉토리는 마지막으로 커밋을 했던 상태로 복원된다.

하지만, 같은 과정을 더 간단하게 실행하는 방법도 있다. 메인 프로젝트를 Clone 할 때 git clone 명령 뒤에 --recurse-submodules 옵션을 붙이면 서브모듈을 자동으로 초기화하고 업데이트한다.

$ git clone --recurse-submodules https://github.com/chaconinc/MainProject
Cloning into 'MainProject'...
remote: Counting objects: 14, done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 14 (delta 1), reused 13 (delta 0)
Unpacking objects: 100% (14/14), done.
Checking connectivity... done.
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) registered for path 'DbConnector'
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.
Submodule path 'DbConnector': checked out 'c3f01dc8862123d317dd46284b05b6892c7b29bc'

서브모듈 포함한 프로젝트 작업

이제 프로젝트에 포함된 서브모듈의 저장소 데이터와 코드도 다 받아왔다. 메인 프로젝트와 서브모듈 프로젝트를 오가며 팀원과 협업할 준비가 되었다.

서브모듈 업데이트하기

가장 단순한 서브모듈 사용 방법은 하위 프로젝트를 수정하지 않고 참조만 하면서 최신 버전으로 업데이트하는 것이다. 간단한 예제로 이 경우를 살펴본다.

서브모듈 프로젝트를 최신으로 업데이트하려면 서브모듈 디렉토리에서 git fetch 명령을 실행하고 git merge 명령으로 Upstream 브랜치를 Merge한다.

$ git fetch
From https://github.com/chaconinc/DbConnector
   c3f01dc..d0354fc  master     -> origin/master
$ git merge origin/master
Updating c3f01dc..d0354fc
Fast-forward
 scripts/connect.sh | 1 +
 src/db.c           | 1 +
 2 files changed, 2 insertions(+)

메인 프로젝트로 돌아와서 git diff --submodule 명령을 실행하면 업데이트된 서브모듈과 각 서브모듈에 추가된 커밋을 볼 수 있다. 매번 --submodule 옵션을 쓰고 싶지 않다면 diff.submodule 의 값을 “log” 로 설정하면 된다.

$ git config --global diff.submodule log
$ git diff
Submodule DbConnector c3f01dc..d0354fc:
  > more efficient db routine
  > better connection routine

여기서 커밋하면 서브모듈은 업데이트된 내용으로 메인 프로젝트에 적용된다. 다른 사람들이 업데이트하면 적용된다.

서브모듈을 최신으로 업데이트하는 더 쉬운 방법도 있다. 서브모듈 디렉토리에서 Fetch 명령과 Merge 명령을 실행하지 않아도 git submodule update --remote 명령을 실행하면 Git이 알아서 서브모듈 프로젝트를 Fetch 하고 업데이트한다.

$ git submodule update --remote DbConnector
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
   3f19983..d0354fc  master     -> origin/master
Submodule path 'DbConnector': checked out 'd0354fc054692d3906c85c3af05ddce39a1c0644'

이 명령은 기본적으로 서브모듈 저장소의 master 브랜치를 Checkout 하고 업데이트를 수행한다. 업데이트할 대상 브랜치를 원하는 브랜치로 바꿀 수 있다. 예를 들어 DbConnector 서브모듈 저장소에서 업데이트할 대상 브랜치를 “stable” 로 바꾸고 싶다면 .gitmodules 파일에 설정하거나(이 파일을 공유하는 모두에게 “stable” 브랜치가 적용됨) 개인 설정 파일인 .git/config 파일에 설정한다. .gitmodules 파일에 설정하는 방법을 알아보자.

$ git config -f .gitmodules submodule.DbConnector.branch stable

$ git submodule update --remote
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
   27cf5d3..c87d55d  stable -> origin/stable
Submodule path 'DbConnector': checked out 'c87d55d4c6d4b05ee34fbc8cb6f7bf4585ae6687'

-f .gitmodules 옵션을 포함하지 않으면 이 설정은 공유하지 않고 사용자에게만 적용된다. 다른 사람과 공유하는 저장소라면 같은 브랜치를 추적하도록 설정하는 것이 더 낫다.

이제 git status 명령를 실행하면 새로 업데이트한 서브모듈에 “new commits” 가 있다는 걸 알 수 있다.

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

  modified:   .gitmodules
  modified:   DbConnector (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

설정 파일에 status.submodulesummary 항목을 설정하면 서브모듈의 변경 사항을 간단히 보여준다.

$ git config status.submodulesummary 1

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   .gitmodules
    modified:   DbConnector (new commits)

Submodules changed but not updated:

* DbConnector c3f01dc...c87d55d (4):
  > catch non-null terminated lines

설정하고 난 후 git diff 명령을 실행해보자. .gitmodules 파일이 변경된 내용은 물론이거니와 업데이트해서 커밋할 필요가 생긴 서브모듈 저장소의 변경 내용을 확인할 수 있다.

$ git diff
diff --git a/.gitmodules b/.gitmodules
index 6fc0b3d..fd1cc29 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,4 @@
 [submodule "DbConnector"]
        path = DbConnector
        url = https://github.com/chaconinc/DbConnector
+       branch = stable
 Submodule DbConnector c3f01dc..c87d55d:
  > catch non-null terminated lines
  > more robust error handling
  > more efficient db routine
  > better connection routine

서브모듈에 실제로 커밋할 커밋들의 정보를 보기에는 꽤 괜찮은 방법이다. 비슷한 식으로 커밋한 후에 로그에서 위와 같이 살펴보려면 git log -p 명령으로 볼 수 있다.

$ git log -p --submodule
commit 0a24cfc121a8a3c118e0105ae4ae4c00281cf7ae
Author: Scott Chacon <schacon@gmail.com>
Date:   Wed Sep 17 16:37:02 2014 +0200

    updating DbConnector for bug fixes

diff --git a/.gitmodules b/.gitmodules
index 6fc0b3d..fd1cc29 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,4 @@
 [submodule "DbConnector"]
        path = DbConnector
        url = https://github.com/chaconinc/DbConnector
+       branch = stable
Submodule DbConnector c3f01dc..c87d55d:
  > catch non-null terminated lines
  > more robust error handling
  > more efficient db routine
  > better connection routine

git submodule update --remote 명령을 실행하면 기본적으로 모든 서브모듈을 업데이트한다. 서브모듈이 엄청 많을 땐 특정 서브모듈만 업데이트하고자 할 수도 있는데 이럴 때는 서브모듈의 이름을 지정해서 명령을 실행한다.

서브모듈 관리하기

메인 프로젝트에서 서브모듈을 사용할 때 서브모듈에서도 뭔가 작업을 해야 할 상황은 얼마든지 생길 수 있다. 메인 프로젝트에서 작업하는 도중에 말이다(동시에 다른 서브모듈도 수정하거나). 만약 Git의 서브모듈 기능을 사용하지 않는다면 다른 Dependency 관리 시스템(Maven이나 Rubygem 같은)을 사용할 수도 있다.

이번 절에서는 서브모듈을 수정하고 그 내용을 담은 커밋을 유지한 채로 메인프로젝트와 서브모듈을 함께 관리하는 방법을 살펴본다.

서브모듈 저장소에서 git submodule update 명령을 실행하면 Git은 서브모듈의 변경 사항을 업데이트한다. 하지만, 서브모듈 로컬 저장소는 “Detached HEAD” 상태로 남는다. 이 말은 변경 내용을 추적하는 로컬 브랜치(예를 들자면 “master” 같은)가 없다는 것이다. 변경 내용을 추적하는 브랜치 없이 서브모듈에서 수정 작업을 하게 되면 이후에 git submodule update 명령을 실행했을 때 수정한 내용을 잃어버릴 수 있다. 서브모듈 안에서 수정사항을 추적하려면 다른 작업이 좀 더 필요하다.

서브모듈이 브랜치를 추적하게 하려면 할 일이 두 가지다. 우선 각 서브모듈 디렉토리로 가서 추적할 브랜치를 Checkout 하고 일을 시작해야 한다. 이후 서브모듈을 수정한 다음에 git submodule update --remote 명령을 실행해 Upstream 에서 새로운 커밋을 가져온다. 이 커밋을 Merge 하거나 Rebase 하는 것은 선택할 수 있다.

먼저 서브모듈 디렉토리로 가서 브랜치를 Checkout 하자.

$ git checkout stable
Switched to branch 'stable'

여기서 “Merge” 를 해보자. update 명령을 쓸 때 --merge 옵션을 추가하면 Merge 하도록 지정할 수 있다. 아래 결과에서 서버로부터 서브모듈의 변경 사항을 가져와서 Merge 하는 과정을 볼 수 있다.

$ git submodule update --remote --merge
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
   c87d55d..92c7337  stable     -> origin/stable
Updating c87d55d..92c7337
Fast-forward
 src/main.c | 1 +
 1 file changed, 1 insertion(+)
Submodule path 'DbConnector': merged in '92c7337b30ef9e0893e758dac2459d07362ab5ea'

DbConnector 디렉토리로 들어가면 새로 수정한 내용이 로컬 브랜치 stable 에 이미 Merge 된 것을 확인할 수 있다. 이제 다른 사람이 DbConnector 라이브러리를 수정해서 Upstream 저장소에 Push 한 상태에서 우리가 DbConnector 라이브러리를 수정하면 무슨 일이 일어나는지 살펴보자.

$ cd DbConnector/
$ vim src/db.c
$ git commit -am 'unicode support'
[stable f906e16] unicode support
 1 file changed, 1 insertion(+)

이제 서브모듈을 업데이트하면 로컬 저장소에서 수정한 것이 무엇인지 Upstream 저장소에서 수정된 것이 무엇인지 볼 수 있다. 이 둘을 합쳐야 한다.

$ git submodule update --remote --rebase
First, rewinding head to replay your work on top of it...
Applying: unicode support
Submodule path 'DbConnector': rebased into '5d60ef9bbebf5a0c1c1050f242ceeb54ad58da94'

--rebase 옵션이나 --merge 옵션을 지정하지 않으면 Git은 로컬 변경사항을 무시하고 서버로부터 받은 해당 서브모듈의 버전으로 Reset을 하고 Detached HEAD 상태로 만든다.

$ git submodule update --remote
Submodule path 'DbConnector': checked out '5d60ef9bbebf5a0c1c1050f242ceeb54ad58da94'

일이 이렇게 되더라도 문제가 안 된다. Reset이 된 서브모듈 디렉토리로 가서 작업하던 브랜치를 Checkout 하고 직접 origin/stable (아니면 원하는 어떠한 리모트 브랜치든)을 Merge 하거나 Rebase 하면 된다.

서브모듈에 커밋하지 않은 변경 사항이 있는 채로 서브모듈을 업데이트하면 Git은 변경 사항을 가져오지만, 서브모듈의 저장하지 않은 작업을 덮어쓰지 않는다.

$ git submodule update --remote
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 4 (delta 0), reused 4 (delta 0)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
   5d60ef9..c75e92a  stable     -> origin/stable
error: Your local changes to the following files would be overwritten by checkout:
    scripts/setup.sh
Please, commit your changes or stash them before you can switch branches.
Aborting
Unable to checkout 'c75e92a2b3855c9e5b66f915308390d9db204aca' in submodule path 'DbConnector'

업데이트 명령을 실행했을 때 Upstream 저장소의 변경 사항과 충돌이 나면 알려준다.

$ git submodule update --remote --merge
Auto-merging scripts/setup.sh
CONFLICT (content): Merge conflict in scripts/setup.sh
Recorded preimage for 'scripts/setup.sh'
Automatic merge failed; fix conflicts and then commit the result.
Unable to merge 'c75e92a2b3855c9e5b66f915308390d9db204aca' in submodule path 'DbConnector'

이러면 서브모듈 디렉토리로 가서 충돌을 해결하면 된다.

서브모듈 수정 사항 공유하기

현재 서브모듈은 변경된 내용을 포함하고 있다. 이 중 일부는 서브모듈 자체를 업데이트하여 Upstream 저장소에서 가져온 것이고 일부는 로컬에서 직접 수정한 내용이다. 로컬에서 수정한 것은 아직 공유하지 않았으므로 아무도 사용할 수 없는 코드이다.

$ git diff
Submodule DbConnector c87d55d..82d2ad3:
  > Merge from origin/stable
  > updated setup script
  > unicode support
  > remove unnecessary method
  > add new option for conn pooling

서브모듈의 변경사항을 Push 하지 않은 채로 메인 프로젝트에서 커밋을 Push 하면 안 된다. 변경 사항을 Checkout 한 다른 사람은 서브모듈이 의존하는 코드를 어디서도 가져올 수 없는 상황이 돼 곤란해진다. 서브모듈의 변경사항은 우리의 로컬에만 있다.

이런 불상사가 발생하지 않도록 하려면 메인 프로젝트를 Push 하기 전에 서브모듈을 모두 Push 했는지 검사하도록 Git에게 물어보면 된다. git push 명령에 --recurse-submodules 옵션을 주고 이 옵션의 값으로 “check” 나 “on-demand” 를 설정한다. “check” 는 간단히 서브모듈의 로컬 커밋이 Push 되지 않은 상태라면 현재의 Push 명령도 실패하도록 하는 옵션이다.

$ git push --recurse-submodules=check
The following submodule paths contain changes that can
not be found on any remote:
  DbConnector

Please try

    git push --recurse-submodules=on-demand

or cd to the path and use

    git push

to push them to a remote.

예제에서 볼 수 있는 대로 이러한 상황에서 다음으로 무엇을 해야 하는지 Git은 도움을 준다. 가장 단순한 방법은 각 서브모듈 디렉토리로 가서 직접 일일이 Push를 해서 외부로 공유하고 나서 메인 프로젝트를 Push 하는 것이다. 이 옵션이 항상 적용되도록 하고 싶으면 git config push.recurseSubmodules check 명령으로 설정한다.

옵션으로 설정할 수 있는 다른 값으로 “on-demand” 값이 있는데, 이 값으로 설정하면 Git이 Push를 대신 시도한다.

$ git push --recurse-submodules=on-demand
Pushing submodule 'DbConnector'
Counting objects: 9, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (9/9), 917 bytes | 0 bytes/s, done.
Total 9 (delta 3), reused 0 (delta 0)
To https://github.com/chaconinc/DbConnector
   c75e92a..82d2ad3  stable -> stable
Counting objects: 2, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 266 bytes | 0 bytes/s, done.
Total 2 (delta 1), reused 0 (delta 0)
To https://github.com/chaconinc/MainProject
   3d6d338..9a377d1  master -> master

위에서 보듯이 Git이 메인 프로젝트를 Push 하기 전에 DbConnector 모듈로 들어가서 Push를 한다. 모종의 이유로 서브모듈 Push에 실패한다면 메인 프로젝트의 Push 또한 실패하게 된다. git config push.recurseSubmodules on-demand 명령으로 설정할 수 있다.

서브모듈 Merge 하기

다른 누군가와 동시에 서브모듈을 수정하면 몇 가지 문제에 봉착하게 된다. 서브모듈의 히스토리가 갈라져서 상위 프로젝트에 커밋했다면 사태를 바로잡아야 한다.

서브모듈의 커밋 두 개를 비교했을 때 Fast-Forward Merge가 가능한 경우 Git은 단순히 마지막 커밋을 선택한다.

하지만, Fast-Forward가 가능하지 않으면 Git은 충돌 없이 Trivial Merge(Merge 커밋을 남기는 Merge)를 할 수 있다 해도 Merge 하지 않는다. 서브모듈 커밋들이 분기됐다가 Merge 해야 하는 경우 아래와 같은 결과를 보게 된다.

$ git pull
remote: Counting objects: 2, done.
remote: Compressing objects: 100% (1/1), done.
remote: Total 2 (delta 1), reused 2 (delta 1)
Unpacking objects: 100% (2/2), done.
From https://github.com/chaconinc/MainProject
   9a377d1..eb974f8  master     -> origin/master
Fetching submodule DbConnector
warning: Failed to merge submodule DbConnector (merge following commits not found)
Auto-merging DbConnector
CONFLICT (submodule): Merge conflict in DbConnector
Automatic merge failed; fix conflicts and then commit the result.

위 결과를 통해 현재 상태를 살펴본다면 Git은 분기된 두 히스토리 브랜치를 찾았고 Merge가 필요하다는 것을 알게 된다. 이 상황은 “merge following commits not found”(Merge 커밋을 찾을 수 없음)라는 메시지로 표현하는데, 의미가 좀 이상하지만 왜 그런지는 이어지는 내용으로 설명한다.

이 문제를 해결하기 위해 서브모듈이 어떤 상태여야 하는지 알아야 한다. 이상하게도 Git은 이를 위한 정보를 충분히 주지 않는다. 양쪽 히스토리에 있는 커밋의 SHA도 알려주지 않는다. 그래도 알아내는 건 간단하다. git diff 명령을 실행하면 Merge 하려는 양쪽 브랜치에 담긴 커밋의 SHA를 알 수 있다.

$ git diff
diff --cc DbConnector
index eb41d76,c771610..0000000
--- a/DbConnector
+++ b/DbConnector

위 같은 경우 eb41d76`이 *로컬* 서브모듈의 커밋이고 `c771610`이 Upstream에 있는 커밋이다. 서브모듈의 디렉토리로 가면 현재 `eb41d76 커밋을 가리키고 있고 Merge 작업은 아직 이루어지지 않았다. 이 상태에서 현재 eb41d76 커밋을 브랜치로 만들어 Merge 작업을 진행할 수 있다.

중요한 건 다른 쪽 커밋의 SHA이다. 이쪽이 Merge 해야 할 대상이다. SHA 해시 값을 명시하여 곧바로 Merge 할 수도 있고 대상이 되는 커밋을 새로 브랜치로 하나 만들어 Merge 할 수도 있다. 더 멋진 Merge 커밋 메시지를 위해서라도 후자를 추천한다.

문제를 해결하기 위해 서브모듈 디렉토리로 이동해서 git diff 에서 나온 두 번째 SHA를 브랜치로 만들고 직접 Merge 한다.

$ cd DbConnector

$ git rev-parse HEAD
eb41d764bccf88be77aced643c13a7fa86714135

$ git branch try-merge c771610
(DbConnector) $ git merge try-merge
Auto-merging src/main.c
CONFLICT (content): Merge conflict in src/main.c
Recorded preimage for 'src/main.c'
Automatic merge failed; fix conflicts and then commit the result.

실제 Merge 시 충돌이 일어났고 해결한 다음 커밋했다. 이후 Merge 한 서브모듈 결과로 메인 프로젝트를 업데이트한다.

$ vim src/main.c (1)
$ git add src/main.c
$ git commit -am 'merged our changes'
Recorded resolution for 'src/main.c'.
[master 9fd905e] merged our changes

$ cd .. (2)
$ git diff (3)
diff --cc DbConnector
index eb41d76,c771610..0000000
--- a/DbConnector
+++ b/DbConnector
@@@ -1,1 -1,1 +1,1 @@@
- Subproject commit eb41d764bccf88be77aced643c13a7fa86714135
 -Subproject commit c77161012afbbe1f58b5053316ead08f4b7e6d1d
++Subproject commit 9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a
$ git add DbConnector (4)

$ git commit -m "Merge Tom's Changes" (5)
[master 10d2c60] Merge Tom's Changes
  1. 먼저 충돌을 해결했다

  2. 그리고 메인 프로젝트로 돌아간다.

  3. SHA-1를 다시 검사하고

  4. 충돌 난 서브모듈을 해결한다.

  5. Merge 결과를 커밋한다.

좀 따라가기 어려울 수 있지만, 사실 그렇게 어려운 건 아니다.

Git으로 이 문제를 해결하는 흥미로운 다른 방법이 있다. 위에서 찾은 두 커밋을 Merge 한 Merge 커밋이 서브모듈 저장소에 존재하면 Git은 이 Merge 커밋을 가능한 해결책으로 내놓는다. 누군가 이미 이 두 커밋을 Merge 한 기록이 있기 때문에 Git은 이 Merge 커밋을 제안한다.

이런 이유에서 위에서 본 Merge 할 수 없다는 오류 메시지가 “merge following commits not found” (Merge 커밋을 찾을 수 없음) 인 것이다. 이런 메시지가 이상한 까닭은 누가 이런 일을 한다고 상상이나 했겠느냐는 말이다.

위의 상황에서 마땅한 Merge 커밋을 하나 발견했다면 아래와 같은 결과를 볼 수 있다.

$ git merge origin/master
warning: Failed to merge submodule DbConnector (not fast-forward)
Found a possible merge resolution for the submodule:
 9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a: > merged our changes
If this is correct simply add it to the index for example
by using:

  git update-index --cacheinfo 160000 9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a "DbConnector"

which will accept this suggestion.
Auto-merging DbConnector
CONFLICT (submodule): Merge conflict in DbConnector
Automatic merge failed; fix conflicts and then commit the result.

Git이 제시한 해결책은 마치 git add 한 것처럼 현재 Index를 업데이트해서 충돌 상황을 해결하고 커밋하라는 것이다. 물론 제시한 해결책을 따르지 않을 수도 있다. 서브모듈 디렉토리로 이동해서 변경사항을 직접 확인하고 Fast-forward Merge를 한 후 Test 해보고 커밋할 수도 있다.

$ cd DbConnector/
$ git merge 9fd905e
Updating eb41d76..9fd905e
Fast-forward

$ cd ..
$ git add DbConnector
$ git commit -am 'Fast forwarded to a common submodule child'

위와 같은 명령으로도 같은 작업을 수행할 수 있다. 이 방법을 사용하면 Merge 커밋에 해당하는 코드로 테스트까지 해 볼 수 있으며, Merge 작업 후에 서브모듈 디렉토리가 해당 코드로 업데이트된다.

서브모듈 팁

서브모듈 작업을 도와줄 몇 가지 팁을 소개한다.

서브모듈 Foreach 여행

foreach 라는 서브모듈 명령이 있어 한 번에 각 서브모듈에 Git 명령을 내릴 수 있다. 한 프로젝트 안에 다수의 서브모듈 프로젝트가 포함된 경우 유용하게 사용할 수 있다.

예를 들어 여러 서브모듈에 걸쳐 작업하던 도중에 새로운 기능을 추가하거나 버그 수정을 해야 하는 경우다. 간단히 아래와 같은 명령으로 한꺼번에 모든 서브모듈에 Stash 명령을 실행할 수 있다.

$ git submodule foreach 'git stash'
Entering 'CryptoLibrary'
No local changes to save
Entering 'DbConnector'
Saved working directory and index state WIP on stable: 82d2ad3 Merge from origin/stable
HEAD is now at 82d2ad3 Merge from origin/stable

위와 같이 명령을 실행하고 나면 모든 서브모듈과 함께 새 브랜치로 이동해서 작업할 준비를 마치게 된다.

$ git submodule foreach 'git checkout -b featureA'
Entering 'CryptoLibrary'
Switched to a new branch 'featureA'
Entering 'DbConnector'
Switched to a new branch 'featureA'

감이 잡히는가? 이 명령을 유용한 경우는 서브모듈을 포함한 메인 프로젝트의 전체 diff 내용을 한꺼번에 결과로 얻고자 하는 경우이다.

$ git diff; git submodule foreach 'git diff'
Submodule DbConnector contains modified content
diff --git a/src/main.c b/src/main.c
index 210f1ae..1f0acdc 100644
--- a/src/main.c
+++ b/src/main.c
@@ -245,6 +245,8 @@ static int handle_alias(int *argcp, const char ***argv)

      commit_pager_choice();

+     url = url_decode(url_orig);
+
      /* build alias_argv */
      alias_argv = xmalloc(sizeof(*alias_argv) * (argc + 1));
      alias_argv[0] = alias_string + 1;
Entering 'DbConnector'
diff --git a/src/db.c b/src/db.c
index 1aaefb6..5297645 100644
--- a/src/db.c
+++ b/src/db.c
@@ -93,6 +93,11 @@ char *url_decode_mem(const char *url, int len)
        return url_decode_internal(&url, len, NULL, &out, 0);
 }

+char *url_decode(const char *url)
+{
+       return url_decode_mem(url, strlen(url));
+}
+
 char *url_decode_parameter_name(const char **query)
 {
        struct strbuf out = STRBUF_INIT;

위의 결과로 알 수 있는 내용은 서브모듈에서 새 함수를 추가했고 메인 프로젝트에서 추가한 함수를 호출한다는 내용이다. 예제로 살펴본 내용은 아주 단순한 예시일 뿐이지만 어떻게 foreach 명령을 유용하게 사용하는지 감 잡을 수 있을 것이다.

유용한 Alias

서브모듈을 이용하는 명령은 대부분 길이가 길어서 Alias를 만들어 사용하는 것이 편하다. 혹은 설정파일을 통해 기본 값으로 모든 명령에 설정하지 않고 쉽게 서브모듈을 사용할 때도 Alias는 유용하다. Alias를 설정하는 방법은 Git Alias에서 이미 다루었다. 여기에서는 서브모듈에 관련된 몇 가지 유용한 Alias만 살펴본다.

$ git config alias.sdiff '!'"git diff && git submodule foreach 'git diff'"
$ git config alias.spush 'push --recurse-submodules=on-demand'
$ git config alias.supdate 'submodule update --remote --merge'

위와 같이 설정하면 git supdate 명령으로 간단히 서브모듈을 업데이트할 수 있고 git spush 명령으로 간단히 서브모듈도 업데이트가 필요한지 확인하며 메인 프로젝트를 Push 할 수 있다.

서브모듈 사용할 때 주의할 점들

전체적으로 서브모듈은 어렵지 않게 사용할 수 있지만, 서브모듈의 코드를 수정하는 경우에는 주의해야 한다.

예를 들어 Checkout으로 브랜치를 변경하는 경우 서브모듈이 포함된 작업이라면 좀 애매하게 동작할 수 있다. 메인 프로젝트에서 새 브랜치를 생성하고 Checkout 한 후 새로 서브모듈을 추가한다. 이후 다시 이전 브랜치로 Checkout 하면 서브모듈 디렉토리는 추적하지 않는 디렉토리로 남게 된다.

$ git checkout -b add-crypto
Switched to a new branch 'add-crypto'

$ git submodule add https://github.com/chaconinc/CryptoLibrary
Cloning into 'CryptoLibrary'...
...

$ git commit -am 'adding crypto library'
[add-crypto 4445836] adding crypto library
 2 files changed, 4 insertions(+)
 create mode 160000 CryptoLibrary

$ git checkout master
warning: unable to rmdir CryptoLibrary: Directory not empty
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)

    CryptoLibrary/

nothing added to commit but untracked files present (use "git add" to track)

물론 추적하지 않는 디렉토리를 지우는 건 쉽다. 이렇게 수동으로 지워야 한다는 게 이상한 것이다. 수동으로 디렉토리를 지우고 다시 서브모듈을 추가했던 브랜치로 Checkout 하면 submodule update --init 명령을 실행해 줘야 서브모듈의 코드가 나타난다(역주 - 이렇게 코드를 가져오고 나면 Detached HEAD가 된다).

$ git clean -ffdx
Removing CryptoLibrary/

$ git checkout add-crypto
Switched to branch 'add-crypto'

$ ls CryptoLibrary/

$ git submodule update --init
Submodule path 'CryptoLibrary': checked out 'b8dda6aa182ea4464f3f3264b11e0268545172af'

$ ls CryptoLibrary/
Makefile	includes	scripts		src

명령이 어려운 건 아니지만, 다시 봐도 이상하다.

또 하나 주의 깊게 살펴볼 일은 서브디렉토리를 서브모듈로 교체하면서 브랜치간 이동하는 경우이다. 메인 프로젝트에서 관리하던 서브디렉토리를 새 서브모듈로 교체할 때 주의를 기울이지 않으면 Git을 집어던지고 싶게 된다. 서브디렉토리를 서브모듈로 교체하는 상황을 살펴보자. 우선 서브디렉토리를 그냥 지우고 바로 서브모듈을 추가한다면 오류가 나타난다.

$ rm -Rf CryptoLibrary/
$ git submodule add https://github.com/chaconinc/CryptoLibrary
'CryptoLibrary' already exists in the index

위와 같은 오류를 해결하려면 우선 CryptoLibrary 디렉토리를 관리대상에서 삭제하고 나서 서브모듈을 추가한다.

$ git rm -r CryptoLibrary
$ git submodule add https://github.com/chaconinc/CryptoLibrary
Cloning into 'CryptoLibrary'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.

위의 작업을 master가 아닌 어떤 브랜치에서 실행한 상황이다. 만약 다시 master 브랜치로 Checkout 하게 되면 서브모듈이 아니라 서브디렉토리가 존재해야 하는 상황이 되는데, 아래와 같은 오류를 만나게 된다.

$ git checkout master
error: The following untracked working tree files would be overwritten by checkout:
  CryptoLibrary/Makefile
  CryptoLibrary/includes/crypto.h
  ...
Please move or remove them before you can switch branches.
Aborting

물론 checkout -f 옵션을 붙여서 강제로 브랜치를 Checkout 할 수 있지만, 서브모듈에서 저장하지 않은 내용을 되돌릴 수 없게 덮어쓰기 때문에 주의 깊게 강제 적용 옵션을 사용해야 한다.

$ git checkout -f master
warning: unable to rmdir CryptoLibrary: Directory not empty
Switched to branch 'master'

후에 다시 서브모듈을 추가했던 브랜치로 Checkout 하면 서브모듈 디렉토리 CryptoLibrary 는 비어 있다. 간혹 git submodule update 명령으로 서브모듈을 초기화하더라도 서브모듈 코드가 살아나지 않을 수 있다. 이럴 때는 서브모듈 디렉토리로 이동해서 git checkout . 명령을 실행하면 서브모듈 코드가 나타난다. 서브모듈을 여러 개 사용하는 경우 submodule foreach 명령으로 한꺼번에 코드를 복구할 수 있다.

최신 버전의 Git은 서브모듈의 커밋 데이터도 메인 프로젝트의 .git 디렉토리에서 관리한다. 예전 버전의 Git과 달리 서브모듈이 포함된 디렉토리를 망가뜨렸다 하더라도 기록해 둔 커밋 데이터는 쉽게 찾을 수 있다.

이런 여러 도구와 함께 서브모듈을 사용한다면 간단하고 효율적으로 메인 프로젝트와 하위 프로젝트를 동시에 관리할 수 있다.