posted by REDFORCE 2017. 4. 13. 04:18

출처 : git-scm.com


8.2 Git맞춤 - Git Attributes

Git Attributes

디렉토리와 파일 단위로 다른 설정을 적용할 수도 있다. 이렇게 경로별로 설정하는 것을 Git Attribute 라고 부른다. 이 설정은 `.gitattributes`라는 파일에 저장하고 아무 디렉토리에나 둘 수 있지만, 보통은 프로젝트 최상위 디렉토리에 둔다. 그리고 이 파일을 커밋하고 싶지 않으면 `.gitattributes`가 아니라 `.git/info/attributes`로 파일을 만든다.

이 Attribute로 Merge는 어떻게 할지, 텍스트가 아닌 파일은 어떻게 Diff 할지, checkin/checkout 할 때 어떻게 필터링할지 정해줄 수 있다. 이 절에서는 설정할 수 있는 Attribute가 어떤 것이 있는지, 그리고 어떻게 설정하는지 배우고 예제를 살펴본다.

바이너리 파일

이 Attribute로 어떤 파일이 바이너리 파일인지 Git에게 알려줄 수 있다. 기본적으로 Git은 어떤 파일이 바이너리 파일인지 알지 못한다. 하지만, Git에는 파일을 어떻게 다뤄야 하는지 알려주는 방법이 있다. 텍스트 파일 중에서 프로그램이 생성하는 파일에는 바이너리 파일과 진배없는 파일이 있다. 이런 파일은 diff 할 수 없으니 바이너리 파일이라고 알려줘야 한다. 반대로 바이너리 파일 중에서 취급 방법을 Git에 알려주면 diff 할 수 있는 파일도 있다. 이어지는 내용으로 어떻게 설정할 수 있는지 살펴보자.

바이너리 파일로 설정

사실 텍스트 파일이지만 만든 목적과 의도를 보면 바이너리 파일인 것이 있다. 예를 들어 Mac의 Xcode는 .pbxproj 파일을 만든다. 이 파일은 IDE 설정 등을 디스크에 저장하는 파일로 JSON 포맷이다. 모든 것이 ASCII인 텍스트 파일이지만 실제로는 간단한 데이터베이스이기 때문에 텍스트 파일처럼 취급할 수 없다. 그래서 여러 명이 이 파일을 동시에 수정하고 Merge 할 때 diff가 도움이 안 된다. 이 파일은 프로그램이 읽고 쓰는 파일이기 때문에 바이너리 파일처럼 취급하는 것이 옳다.

모든 pbxproj 파일을 바이너리로 파일로 취급하는 설정은 아래와 같다. .gitattributes 파일에 넣으면 된다.

   *.pbxproj binary

이제 pbxproj 파일은 CRLF 변환이 적용되지 않는다. git show`나 `git diff 같은 명령을 실행할 때도 통계를 계산하거나 diff를 출력하지 않는다.

바이너리 파일 Diff 하기

Git은 바이너리 파일도 Diff 할 수 있다. Git Attribute를 통해 Git이 바이너리 파일을 텍스트 포맷으로 변환하고 그 결과를 diff 명령으로 비교하도록 하는 것이다.

먼저 이 기술을 인류에게 알려진 가장 귀찮은 문제 중 하나인 Word 문서를 버전 관리하는 상황을 살펴보자. 모든 사람이 Word가 가장 끔찍한 편집기라고 말하지만 애석하게도 모두 Word를 사용한다. Git 저장소에 넣고 이따금 커밋하는 것만으로도 Word 문서의 버전을 관리할 수 있다. 그렇지만 `git diff`를 실행하면 아래와 같은 메시지를 볼 수 있을 뿐이다.

   $ git diff
   diff --git a/chapter1.docx b/chapter1.docx
   index 88839c4..4afcb7c 100644
   Binary files a/chapter1.docx and b/chapter1.docx differ

직접 파일을 하나하나 까보지 않으면 두 버전이 뭐가 다른지 알 수 없다. Git Attribute를 사용하면 이를 더 좋게 개선할 수 있다. .gitattributes 파일에 아래와 같은 내용을 추가한다.

   *.docx diff=word

이것은 *.docx 파일의 두 버전이 무엇이 다른지 Diff 할 때 “word” 필터를 사용하라고 설정하는 것이다. 그럼 “word” 필터는 뭘까? 이 “word” 필터도 정의해야 한다. Word 문서에서 사람이 읽을 수 있는 텍스트를 추출해주는 docx2txt 프로그램을 사용하여 Diff에 이용한다.

우선 docx2txt 프로그램을 설치해야 하는데 http://docx2txt.sourceforge.net 사이트에서 다운로드 할 수 있다. INSTALL 부분의 설치과정을 참고하여 설치하고 쉘에서 실행할 수 있도록 설정한다. 그리고 Git에서 잘 쓸 수 있도록 Wrapper 스크립트를 docx2txt 라는 이름으로 아래와 같이 작성한다.

   #!/bin/bash
   docx2txt.pl $1 -

`chmod a+x`로 실행권한을 설정해두고 아래와 같이 Git 설정을 추가한다.

   $ git config diff.word.textconv docx2txt

이제 Git은 확장자가 .docx`인 파일의 스냅샷을 Diff 할 때 ``word'' 필터로 정의한 `docx2txt 프로그램을 사용한다. 이 프로그램은 Word 파일을 텍스트 파일로 변환해 주기 때문에 Diff 할 수 있다.

이 책의 1장을 Word 파일로 만들어서 Git에 넣고 나서 단락 하나를 수정하고 저장하는 예를 살펴본다. 새로 단락을 하나 추가하고 나서 `git diff`를 실행하면 어디가 달려졌는지 확인할 수 있다.

   $ git diff
   diff --git a/chapter1.docx b/chapter1.docx
   index 0b013ca..ba25db5 100644
   --- a/chapter1.docx
   +++ b/chapter1.docx
   @@ -2,6 +2,7 @@
    This chapter will be about getting started with Git. We will begin at the beginning by explaining some background on version control tools, then move on to how to get Git running on your system and finally how to get it setup to start working with. At the end of this chapter you should understand why Git is around, why you should use it and you should be all setup to do so.
    1.1. About Version Control
    What is "version control", and why should you care? Version control is a system that records changes to a file or set of files over time so that you can recall specific versions later. For the examples in this book you will use software source code as the files being version controlled, though in reality you can do this with nearly any type of file on a computer.
   +Testing: 1, 2, 3.
    If you are a graphic or web designer and want to keep every version of an image or layout (which you would most certainly want to), a Version Control System (VCS) is a very wise thing to use. It allows you to revert files back to a previous state, revert the entire project back to a previous state, compare changes over time, see who last modified something that might be causing a problem, who introduced an issue and when, and more. Using a VCS also generally means that if you screw things up or lose files, you can easily recover. In addition, you get all this for very little overhead.
    1.1.1. Local Version Control Systems
    Many people's version-control method of choice is to copy files into another directory (perhaps a time-stamped directory, if they're clever). This approach is very common because it is so simple, but it is also incredibly error prone. It is easy to forget which directory you're in and accidentally write to the wrong file or copy over files you don't mean to.

git diff 명령의 결과를 보면 “Testing: 1, 2, 3.” 부분이 추가된 것을 확인할 수 있다. 물론 텍스트 형식 같은 완전한 정보는 아니지만 어쨌든 유용하다.

이 방법으로 이미지 파일도 Diff 할 수 있다. 필터로 EXIF 정보를 추출해서 이미지 파일을 비교한다. EXIF 정보는 대부분의 이미지 파일에 들어 있는 메타데이터다. exiftool 프로그램을 설치하고 이미지 파일에서 메타데이터 텍스트를 추출한다. 그리고 그 결과를 Diff 해서 무엇이 달라졌는지 본다. 다음 내용을 .gitattributes 파일로 저장한다.

   *.png diff=exif

Git에서 위 설정을 사용하려면 다음과 같이 설정한다.

   $ git config diff.exif.textconv exiftool

프로젝트에 들어 있는 이미지 파일을 변경하고 `git diff`를 실행하면 아래와 같이 보여준다.

   diff --git a/image.png b/image.png
   index 88839c4..4afcb7c 100644
   --- a/image.png
   +++ b/image.png
   @@ -1,12 +1,12 @@
    ExifTool Version Number         : 7.74
   -File Size                       : 70 kB
   -File Modification Date/Time     : 2009:04:21 07:02:45-07:00
   +File Size                       : 94 kB
   +File Modification Date/Time     : 2009:04:21 07:02:43-07:00
    File Type                       : PNG
    MIME Type                       : image/png
   -Image Width                     : 1058
   -Image Height                    : 889
   +Image Width                     : 1056
   +Image Height                    : 827
    Bit Depth                       : 8
    Color Type                      : RGB with Alpha

이미지 파일의 크기와 해상도가 달라진 것을 쉽게 알 수 있다.

키워드 치환

SVN이나 CVS에 익숙한 사람들은 해당 시스템에서 사용하던 키워드 치환(Keyword Expansion) 기능을 찾는다. Git에서는 이것이 쉽지 않다. Git은 먼저 체크섬을 계산하고 커밋하기 때문에 그 커밋에 대한 정보를 가지고 파일을 수정할 수 없다. 하지만, Checkout 할 때 그 정보가 자동으로 파일에 삽입되도록 했다가 다시 커밋할 때 삭제되도록 할 수 있다.

파일 안에 $Id$ 필드를 넣으면 Blob의 SHA-1 체크섬을 자동으로 삽입한다. 이 필드를 파일에 넣으면 Git은 앞으로 Checkout 할 때 해당 Blob의 SHA-1 값으로 교체한다. 여기서 꼭 기억해야 할 것이 있다. 교체되는 체크섬은 커밋의 것이 아니라 Blob 그 자체의 SHA-1 체크섬이다. 다음 내용을 .gitattributes 파일로 저장한다.

   *.txt ident

테스트 할 파일에 $Id$ 레퍼런스를 넣고 저장한다.

   $ echo '$Id$' > test.txt

Git은 이 파일을 Checkout 할 때마다 SHA 값을 삽입해준다.

   $ rm test.txt
   $ git checkout -- test.txt
   $ cat test.txt
   $Id: 42812b7653c7b88933f8a9d6cad0ca16714b9bb3 $

하지만, 이것은 별로 유용하지 않다. CVS나 SVN의 키워드 치환(Keyword Substitution)을 써봤으면 날짜(Datestamp)도 가능했다는 것을 알고 있을 것이다. SHA는 그냥 해시이고 식별할 수 있을 뿐이지 다른 것을 알려주진 않는다. SHA만으로는 예전 것보다 새것인지 오래된 것인지는 알 수 없다.

Commit/Checkout 할 때 사용하는 필터를 직접 만들어 쓸 수 있다. 방향에 따라 “clean” 필터와 “smudge” 필터라고 부른다. ".gitattributes" 파일에 설정하고 파일 경로마다 다른 필터를 설정할 수 있다. Checkout 할 때 파일을 처리하는 것이 “smudge” 필터이고(“smudge” 필터는 Checkout 할 때 실행됨.) 커밋할 때 처리하는 필터가 “clean”(“clean” 필터는 파일을 Stage 할 때 실행됨.) 필터이다. 이 필터로 할 수 있는 일은 무궁무진하다.

``smudge'' 필터는 Checkout 할 때 실행됨.
Figure 144. “smudge” 필터는 Checkout 할 때 실행됨.
``clean'' 필터는 파일을 Stage 할 때 실행됨.
Figure 145. “clean” 필터는 파일을 Stage 할 때 실행됨.

이 기능은 사실 커밋 메시지를 위한 기능이었지만 응용한다면 커밋하기 전에 indent 프로그램으로 C 코드 전부를 필터링하는 기능을 만들 수 있다. *.c 파일에 대해 indent 필터를 거치도록 .gitattributes 파일에 설정한다.

   *.c filter=indent

아래처럼 “indent” 필터의 smudge와 clean이 무엇인지 설정한다.

   $ git config --global filter.indent.clean indent
   $ git config --global filter.indent.smudge cat

*.c 파일을 커밋하면 indent 프로그램을 통해서 커밋되고 Checkout 하면 cat 프로그램을 통해 Checkout된다. cat`은 입력된 데이터를 그대로 다시 내보내는 사실 아무것도 안 하는 프로그램이다. 이렇게 설정하면 모든 C 소스 파일은 `indent 프로그램을 통해 커밋된다.

이제 RCS처럼 $Date$`를 치환하는 예제를 살펴보자. 이 기능을 구현하려면 간단한 스크립트가 하나 필요하다. 이 스크립트는 `$Date$ 필드를 프로젝트의 마지막 커밋 일자로 치환한다. 표준 입력을 읽어서 $Date$ 필드를 치환한다. 아래는 Ruby로 구현한 스크립트다.

   #! /usr/bin/env ruby
   data = STDIN.read
   last_date = `git log --pretty=format:"%ad" -1`
   puts data.gsub('$Date$', '$Date: ' + last_date.to_s + '$')

git log 명령으로 마지막 커밋 정보를 얻고 표준 입력(STDIN)에서 $Date$ 스트링을 찾아서 치환한다. 스크립트는 자신이 편한 언어로 만든다. 이 스크립트의 이름을 `expand_date`라고 짓고 실행 경로에 넣는다. 그리고 `dater`라는 Git 필터를 정의한다. Checkout시 실행하는 smudge 필터로 `expand_date`를 사용하고 커밋할 때 실행하는 clean 필터는 Perl을 사용한다.

   $ git config filter.dater.smudge expand_date
   $ git config filter.dater.clean 'perl -pe "s/\\\$Date[^\\\$]*\\\$/\\\$Date\\\$/"'

이 Perl 코드는 $Date$ 스트링에 있는 문자를 제거해서 원래대로 복원한다. 이제 필터가 준비됐으니 $Date$ 키워드가 들어 있는 파일을 만들고 Git Attribute를 설정하고 새 필터를 시험해보자.

   date*.txt filter=dater
   $ echo '# $Date$' > date_test.txt

커밋하고 파일을 다시 Checkout 하면 해당 키워드가 적절히 치환된 것을 볼 수 있다.

   $ git add date_test.txt .gitattributes
   $ git commit -m "Testing date expansion in Git"
   $ rm date_test.txt
   $ git checkout date_test.txt
   $ cat date_test.txt
   # $Date: Tue Apr 21 07:26:52 2009 -0700$

이 기능은 매우 강력해서 입맛대로 프로젝트를 맞춤 설정할 수 있다. .git attributes 파일은 커밋하는 파일이기 때문에 필터 드라이버(여기서는 dater) 설정이 되지 않은 사람에게도 배포된다. 물론 dater 설정이 안 돼 있는 사람에게는 에러가 난다. 필터를 만들 때 이런 예외 상황도 고려해서 항상 잘 동작하게 해야 한다.

저장소 익스포트하기

프로젝트를 익스포트해서 아카이브를 만들 때도 Git Attribute가 유용하다.

export-ignore

아카이브를 만들 때 제외할 파일이나 디렉토리가 무엇인지 설정할 수 있다. 특정 디렉토리나 파일을 프로젝트에는 포함하고 아카이브에는 포함하고 싶지 않을 때 export-ignore Attribute를 사용한다.

예를 들어 test/ 디렉토리에 테스트 파일이 있다고 하자. 보통 tar 파일로 묶어서 익스포트할 때 테스트 파일은 포함하지 않는다. Git Attribute 파일에 아래 라인을 추가하면 테스트 파일은 무시된다.

   test/ export-ignore

git archive 명령으로 tar 파일을 만들면 test 디렉토리는 아카이브에 포함되지 않는다.

export-subst

아카이브를 만들어서 배포할 때도 git log 같은 포맷 규칙을 적용할 수 있다. export-subst Attribute로 설정한 파일들의 키워드가 치환된다.

git archive 명령을 실행할 때 자동으로 마지막 커밋의 메타데이터가 자동으로 삽입되게 할 수 있다. 예를 들어 .gitattributes 파일과 LAST_COMMIT 파일을 아래와 같이 만든다.

   LAST_COMMIT export-subst
   $ echo 'Last commit date: $Format:%cd by %aN$' > LAST_COMMIT
   $ git add LAST_COMMIT .gitattributes
   $ git commit -am 'adding LAST_COMMIT file for archives'

git archive 명령으로 아카이브를 만들고 나서 이 파일을 열어보면 아래와 같이 보인다.

   $ git archive HEAD | tar xCf ../deployment-testing -
   $ cat ../deployment-testing/LAST_COMMIT
   Last commit date: Tue Apr 21 08:38:48 2009 -0700 by Scott Chacon

이 키워드 치환 기능으로 커밋 메시지와 Git 노트, Git Log도 넣을 수 있다. 어렵지 않다.

   $ echo '$Format:Last commit: %h by %aN at %cd%n%+w(76,6,9)%B$' > LAST_COMMIT
   $ git commit -am 'export-subst uses git log's custom formatter

   git archive uses git log's `pretty=format:` processor
   directly, and strips the surrounding `$Format:` and `$`
   markup from the output.
   '
   $ git archive @ | tar xfO - LAST_COMMIT
   Last commit: 312ccc8 by Jim Hill at Fri May 8 09:14:04 2015 -0700
          export-subst uses git log's custom formatter

            git archive uses git log's `pretty=format:` processor directly, and
            strips the surrounding `$Format:` and `$` markup from the output.

이 아카이브 기능은 개발할 때가 아니라 배포할 때 좋다.

Merge 전략

파일마다 다른 Merge 전략을 사용하도록 설정할 수 있다. Merge 할 때 충돌이 날 것 같은 파일이 있다고 하자. Git Attrbute로 이 파일만 항상 타인의 코드 말고 내 코드를 사용하도록 설정할 수 있다.

이 설정은 다양한 환경에서 운영하려고 만든 환경 브랜치를 Merge 할 때 좋다. 이때는 환경 설정과 관련된 파일은 Merge 하지 않고 무시하는 게 편리하다. 브랜치에 `database.xml`이라는 데이터베이스 설정파일이 있는데 이 파일은 브랜치마다 다르다. Database 설정 파일은 Merge 하면 안된다. Attribute를 아래와 같이 설정하면 이 파일은 그냥 두고 Merge 한다.

   database.xml merge=ours
   And then define a dummy `ours` merge strategy with:

$ git config --global merge.ours.driver true

다른 브랜치로 이동해서 Merge를 실행했을 때 database.xml 파일에 대해 충돌이 발생하는 대신 아래와 같은 메시지를 보게 된다.

   $ git merge topic
   Auto-merging database.xml
   Merge made by recursive.

Merge 했지만 database.xml 파일은 원래 가지고 있던 파일 그대로다.


posted by REDFORCE 2017. 4. 13. 03:12

출처 : git-scm.com


8.1 Git맞춤 - Git 설정하기

지금까지 Git이 어떻게 동작하고 Git을 어떻게 사용하는지 설명했다. 이제 Git을 좀 더 쉽고 편하게 사용할 수 있도록 도와주는 도구를 살펴본다. 이 장에서는 먼저 많이 쓰이는 설정 그리고 훅 시스템을 먼저 설명한다. 그 후에 Git을 내게 맞추어(Customize) 본다. Git을 자신의 프로젝트에 맞추고 편하게 사용하자.

Git 설정하기

시작하기 에서 git config 명령을 사용했다. 제일 먼저 해야 하는 것은 git config 명령으로 이름과 이메일 주소를 설정하는 것이다.

   $ git config --global user.name "John Doe"
   $ git config --global user.email johndoe@example.com

여기서는 이렇게 설정하는 것 중에서 중요한 것만 몇 가지 배운다.

우선 Git은 내장된 기본 규칙 따르지만, 설정된 것이 있으면 그에 따른다는 점을 생각해두자. Git은 먼저 /etc/gitconfig 파일을 찾는다. 이 파일은 해당 시스템에 있는 모든 사용자와 모든 저장소에 적용되는 설정 파일이다. git config 명령에 --system 옵션을 주면 이 파일을 사용한다.

다음으로 ~/.gitconfig 파일을 찾는다. 이 파일은 해당 사용자에게만 적용되는 설정 파일이다. --global옵션을 주면 Git은 이 파일을 사용한다.

마지막으로 현재 작업 중인 저장소의 Git 디렉토리에 있는 .git/config 파일을 찾는다. 이 파일은 해당 저장소에만 적용된다.

각 설정 파일에 중복된 설정이 있으면 설명한 “순서대로” 덮어쓴다. 예를 들어 `.git/config`와 `/etc/gitconfig`에 같은 설정이 들어 있다면 `.git/config`에 있는 설정을 사용한다.

Note

설정 파일 일반적인 텍스트파일로 쉽게 고쳐 쓸 수 있다. 보통 git config 명령을 사용하는 것이 더 편하다.

클라이언트 설정

설정이 영향을 미치는 대상에 따라 클라이언트 설정과 서버 설정으로 나눠볼 수 있다. 대부분은 개인작업 환경과 관련된 클라이언트 설정이다. Git에는 설정거리가 매우 많은데, 여기서는 워크플로를 관리하는 데 필요한 것과 주로 많이 사용하는 것만 설명한다. 한 번도 겪지 못할 상황에서나 유용한 옵션까지 다 포함하면 설정할 게 너무 많다. Git 버전마다 옵션이 조금씩 다른데, 아래와 같이 실행하면 설치한 버전에서 사용할 수 있는 옵션을 모두 보여준다.

   $ man git-config

어떤 옵션을 사용할 수 있는지 매우 자세히 설명하고 있다. http://git-scm.com/docs/git-config.html 페이지에서도 같은 내용을 볼 수 있다.

core.editor

Git은 편집기를 설정($VISUAL$EDITOR 변수로 설정)하지 않았거나 설정한 편집기를 찾을 수 없으면 vi`를 실행한다. 커밋할 때나 tag 메시지를 편집할 때 설정한 편집기를 실행한다. `code.editor 설정으로 편집기를 설정한다.

   $ git config --global core.editor emacs

이렇게 설정하면 메시지를 편집할 때 환경변수에 설정한 편집기가 아니라 Emacs를 실행한다.

commit.template

커밋할 때 Git이 보여주는 커밋 메시지는 이 옵션에 설정한 템플릿 파일이다. 예를 들어 ~/.gitmessage.txt 파일을 아래와 같이 만든다.

   subject line

   what happened

   [ticket: X]

이 파일을 commit.template`에 설정하면 Git은 `git commit 명령이 실행하는 편집기에 이 메시지를 기본으로 넣어준다.

   $ git config --global commit.template ~/.gitmessage.txt
   $ git commit

그러면 커밋할 때 아래와 같은 메시지를 편집기에 자동으로 채워준다.

   subject line

   what happened

   [ticket: X]
   # Please enter the commit message for your changes. Lines starting
   # with '#' will be ignored, and an empty message aborts the commit.
   # On branch master
   # Changes to be committed:
   #   (use "git reset HEAD <file>..." to unstage)
   #
   # modified:   lib/test.rb
   #
   ~
   ~
   ".git/COMMIT_EDITMSG" 14L, 297C

소속 팀에 커밋 메시지 규칙이 있으면 그 규칙에 맞는 템플릿 파일을 만들고 시스템 설정에 설정해둔다. Git이 그 파일을 사용하도록 설정하면 규칙을 따르기가 쉬워진다.

core.pager

Git은 `log`나 `diff`같은 명령의 메시지를 출력할 때 페이지로 나누어 보여준다. 기본으로 사용하는 명령은 `less`다. `more`를 더 좋아하면 `more`라고 설정한다. 페이지를 나누고 싶지 않으면 빈 문자열로 설정한다.

   $ git config --global core.pager ''

이 명령을 실행하면 Git은 길든지 짧든지 결과를 한 번에 다 보여 준다.

user.signingkey

이 설정은 내 작업에 서명하기 에서 설명했던 Annotated Tag를 만들 때 유용하다. 사용할 GPG 키를 설정해 둘 수 있다. 아래처럼 GPG 키를 설정하면 서명할 때 편리하다.

   $ git config --global user.signingkey <gpg-key-id>

git tag 명령을 실행할 때 키를 생략하고 서명할 수 있다.

   $ git tag -s <tag-name>

core.excludesfile

Git에서 git add 명령으로 추적할 파일에 포함하지 않을 파일은 `.gitignore`에 해당 패턴을 적으면 된다고 파일 무시하기에서 설명했다.

한 저장소 안에서뿐 아니라 어디에서라도 Git에 포함하지 않을 파일을 설정할 수 있다. 예를 들어 Mac을 쓰는 사람이라면 .DS_Store 파일을 자주 보았을 것이다. Emacs나 Vim를 쓰다 보면 이름 끝에 ~ 붙여둔 임시 파일도 있다.

.gitignore 파일처럼 무시할 파일을 설정할 수 있는데 ~/.gitignore_global 파일 안에 아래 내용으로 입력해두고

   *~
   .*.swp
   .DS_Store

git config --global core.excludesfile ~/.gitignore_global 명령으로 설정을 추가하면 더는 위와 같은 파일이 포함되지 않을 것이다.

help.autocorrect

명령어를 잘못 입력하면 Git은 메시지를 아래와 같이 보여 준다.

$ git chekcout master
git: 'chekcout' is not a git command. See 'git --help'.

Did you mean this?
    checkout

Git은 어떤 명령을 입력하려고 했을지 추측해서 보여주긴 하지만 직접 실행하진 않는다. 그러나 `help.autocorrect`를 1로 설정하면 명령어를 잘못 입력해도 Git이 자동으로 해당 명령어를 찾아서 실행해준다.

   $ git chekcout master
   WARNING: You called a Git command named 'chekcout', which does not exist.
   Continuing under the assumption that you meant 'checkout'
   in 0.1 seconds automatically...

여기서 재밌는 것은 “0.1 seconds” 이다. 사실 help.autocorrect 설정에 사용하는 값은 1/10 초 단위의 숫자를 나타낸다. 만약 50이라는 값으로 설정한다면 자동으로 고친 명령을 실행할 때 Git은 5초간 명령을 실행하지 않고 기다려줄 수 있다.

컬러 터미널

사람이 쉽게 인식할 수 있도록 터미널에 결과를 컬러로 출력할 수 있다. 터미널 컬러와 관련된 옵션은 매우 다양하기 때문에 꼼꼼하게 설정할 수 있다.

color.ui

Git은 기본적으로 터미널에 출력하는 결과물을 알아서 색칠하지만, 이 색칠하는 기능을 끄고 싶다면 한 가지 설정만 해 두면 된다. 아래와 같은 명령을 실행하면 더는 색칠된 결과물을 내지 않는다.

   $ git config --global color.ui false

컬러 설정의 기본 값은 `auto`로 터미널에 출력할 때는 색칠하지만, 결과가 리다이렉션되거나 파일로 출력되면 색칠하지 않는다.

always`로 설정하면 터미널이든 다른 출력이든 상관없이 색칠하여 내보낸다. 대개 이 값을 설정해서 사용하지 않는다. `--color 옵션을 사용하면 강제로 결과를 색칠해서 내도록 할 수 있기 때문이다. 대부분은 기본 값이 낫다.

color.*

Git은 좀 더 꼼꼼하게 컬러를 설정하는 방법을 제공한다. 아래와 같은 설정들이 있다. 모두 truefalsealways 중 하나를 고를 수 있다.

   color.branch
   color.diff
   color.interactive
   color.status

또한, 각 옵션의 컬러를 직접 지정할 수도 있다. 아래처럼 설정하면 diff 명령에서 meta 정보의 포그라운드는 blue, 백그라운드는 black, 테스트는 bold로 바뀐다.

   $ git config --global color.diff.meta "blue black bold"

컬러는 normalblackredgreenyellowbluemagentacyanwhite 중에서 고를 수 있다. 텍스트 속성은 bolddimul (underline), blinkreverse 중에서 고를 수 있다.

다른 Merge, Diff 도구 사용하기

Git에 들어 있는 diff 도구 말고 다른 도구로 바꿀 수 있다. 화려한 GUI 도구로 바꿔서 좀 더 편리하게 충돌을 해결할 수 있다. 여기서는 Perforce의 Merge 도구인 P4Merge로 설정하는 것을 보여준다. P4Merge는 무료인데다 꽤 괜찮다.

P4Merge는 중요 플랫폼을 모두 지원하기 때문에 웬만한 환경이면 사용할 수 있다. 여기서는 Mac과 Linux 시스템에 설치하는 것을 보여준다. Windows에서 사용하려면 /usr/local/bin 경로만 Windows 경로로 바꿔준다.

먼저 https://www.perforce.com/product/components/perforce-visual-merge-and-diff-tools 에서 P4Merge를 내려받는다. 그 후에 P4Merge 에 쓸 Wrapper 스크립트를 만든다. 필자는 Mac 사용자라서 Mac 경로를 사용한다. 어떤 시스템이든 `p4merge`가 설치된 경로를 사용하면 된다. 예제에서는 `extMerge`라는 Merge 용 Wrapper 스크립트를 만들고 이 스크립트로 넘어오는 모든 아규먼트를 p4merge 프로그램으로 넘긴다.

   $ cat /usr/local/bin/extMerge
   #!/bin/sh
   /Applications/p4merge.app/Contents/MacOS/p4merge $*

그리고 diff용 Wrapper도 만든다. 이 스크립트로 넘어오는 아규먼트는 총 7개지만 그 중 2개만 Merge Wrapper로 넘긴다. Git이 diff 프로그램에 넘겨주는 아규먼트는 아래와 같다.

   path old-file old-hex old-mode new-file new-hex new-mode

이 중에서 old-file`과 `new-file 만 사용하는 wrapper 스크립트를 만든다.

   $ cat /usr/local/bin/extDiff
   #!/bin/sh
   [ $# -eq 7 ] && /usr/local/bin/extMerge "$2" "$5"

이 두 스크립트에 실행 권한을 부여한다.

   $ sudo chmod +x /usr/local/bin/extMerge
   $ sudo chmod +x /usr/local/bin/extDiff

Git config 파일에 이 스크립트를 모두 추가한다. 설정해야 하는 옵션이 좀 많다. merge.tool`로 무슨 Merge 도구를 사용할지, `mergetool.*.cmd`로 실제로 어떻게 명령어를 실행할지, `mergetool.trustExitCode`로 Merge 도구가 반환하는 exit 코드가 Merge의 성공 여부를 나타내는지, `diff.external`은 diff 할 때 실행할 명령어가 무엇인지를 설정할 때 사용한다. 모두 `git config명령으로 설정한다.

   $ git config --global merge.tool extMerge
   $ git config --global mergetool.extMerge.cmd \
     'extMerge \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"'
   $ git config --global mergetool.extMerge.trustExitCode false
   $ git config --global diff.external extDiff

~/.gitconfig/ 파일을 직접 편집해도 된다.

   [merge]
     tool = extMerge
   [mergetool "extMerge"]
     cmd = extMerge "$BASE" "$LOCAL" "$REMOTE" "$MERGED"
     trustExitCode = false
   [diff]
     external = extDiff

설정을 완료하고 나서 아래와 같이 diff 명령어를 실행한다.

   $ git diff 32d1776b1^ 32d1776b1

diff 결과가 터미널에 출력되는 대신 P4Merge가 실행되어 아래처럼 Diff 결과를 보여준다.

P4Merge.
Figure 143. P4Merge.

브랜치를 Merge 할 때 충돌이 나면 git mergetool 명령을 실행한다. 이 명령을 실행하면 GUI 도구로 충돌을 해결할 수 있도록 P4Merge를 실행해준다.

Wrapper를 만들어 설정해두면 다른 Diff, Merge 도구로 바꾸기도 쉽다. 예를 들어, KDiff3를 사용하도록 extDiff`와 `extMerge 스크립트를 수정한다.

   $ cat /usr/local/bin/extMerge
   #!/bin/sh
/Applications/kdiff3.app/Contents/MacOS/kdiff3 $*

이제부터 Git은 Diff 결과를 보여주거나 충돌을 해결할 때 KDiff3 도구를 사용한다.

어떤 Merge 도구는 Git에 미리 설정이 들어 있다. 그래서 추가로 스크립트를 작성하거나 하는 설정 없이 사용할 수 있는 것도 있다. 아래와 같은 명령으로 확인해볼 수 있다.

   $ git mergetool --tool-help
   'git mergetool --tool=<tool>' may be set to one of the following:
           emerge
           gvimdiff
           gvimdiff2
           opendiff
           p4merge
           vimdiff
           vimdiff2

   The following tools are valid, but not currently available:
           araxis
           bc3
           codecompare
           deltawalker
           diffmerge
           diffuse
           ecmerge
           kdiff3
           meld
           tkdiff
           tortoisemerge
           xxdiff

   Some of the tools listed above only work in a windowed
   environment. If run in a terminal-only session, they will fail.

Diff 도구로는 다른 것을 사용하지만, Merge 도구로는 KDiff3를 사용하고 싶은 경우에는 kdiff3 명령을 실행경로로 넣고 아래와 같이 설정하기만 하면 된다.

   $ git config --global merge.tool kdiff3

extMerge`와 `extDiff 파일을 만들지 않고 이렇게 Merge 도구만 `kdiff3`로 설정하고 Diff 도구는 Git에 원래 들어 있는 것을 사용할 수 있다.

Formatting and Whitespace

협업할 때 겪는 소스 포맷(Formatting)과 공백 문제는 미묘하고 난해하다. 동료 사이에 사용하는 플랫폼이 다를 때는 특히 더 심하다. 다른 사람이 보내온 Patch는 공백 문자 패턴이 미묘하게 다를 확률이 높다. 편집기가 몰래 공백문자를 추가해 버릴 수도 있고 크로스-플랫폼 프로젝트에서 Windows 개발자가 라인 끝에 CR(Carriage-Return) 문자를 추가해 버렸을 수도 있다. Git에는 이 이슈를 돕는 몇 가지 설정이 있다.

core.autocrlf

Windows에서 개발하는 동료와 함께 일하면 라인 바꿈(New Line) 문자에 문제가 생긴다. Windows는 라인 바꿈 문자로 CR(Carriage-Return)과 LF(Line Feed) 문자를 둘 다 사용하지만, Mac과 Linux는 LF 문자만 사용한다. 아무것도 아닌 것 같지만, 크로스 플랫폼 프로젝트에서는 꽤 성가신 문제다. Windows에서 사용하는 많은 편집기가 자동으로 LF 스타일의 라인 바꿈 스타일을 CRLF로 바꾸거나 Enter 키를 입력하면 CRLF 스타일을 사용하기 때문이다.

Git은 커밋할 때 자동으로 CRLF를 LF로 변환해주고 반대로 Checkout 할 때 LF를 CRLF로 변환해 주는 기능이 있다. core.autocrlf 설정으로 이 기능을 켤 수 있다. Windows에서 이 값을 true로 설정하면 Checkout 할 때 LF 문자가 CRLF 문자로 변환된다.

   $ git config --global core.autocrlf true

라인 바꿈 문자로 LF를 사용하는 Linux와 Mac에서는 Checkout 할 때 Git이 LF를 CRLF로 변환할 필요가 없다. 게다가 우연히 CRLF가 들어간 파일이 저장소에 들어 있어도 Git이 알아서 고쳐주면 좋을 것이다.core.autocrlf 값을 input으로 설정하면 커밋할 때만 CRLF를 LF로 변환한다.

   $ git config --global core.autocrlf input

이 설정을 이용하면 Windows에서는 CRLF를 사용하고 Mac, Linux, 저장소에서는 LF를 사용할 수 있다.

Windows 플랫폼에서만 개발하면 이 기능이 필요 없다. 이 옵션을 `false`라고 설정하면 이 기능이 꺼지고 CR 문자도 저장소에도 저장된다.

   $ git config --global core.autocrlf false

core.whitespace

Git에는 공백 문자를 다루는 방법으로 네 가지가 미리 정의돼 있다. 두 가지는 기본적으로 켜져 있지만 끌 수 있고 나머지 두 가지는 꺼져 있지만 켤 수 있다.

먼저 기본적으로 켜져 있는 것을 살펴보자. `blank-at-eol`는 각 라인 끝에 공백이 있는지 찾고, `blank-at-eof`는 파일 끝에 추가한 빈 라인이 있는지 찾고, `space-before-tab`은 모든 라인에서 처음에 tab보다 공백이 먼저 나오는지 찾는다.

기본적으로 꺼져 있는 나머지 세 개는 indent-with-non-tab`과 `tab-in-indent`과 `cr-at-eol`이다. `intent-with-non-tab`은 tab이 아니라 공백으로(`tabwidth 설정에 영향받음) 시작하는 라인이 있는지 찾고 `cr-at-eol`은 라인 끝에 CR 문자가 있어도 괜찮다고 Git에 알리는 것이다.

core.whitespace 옵션으로 이 네 가지 방법을 켜고 끌 수 있다. 설정에서 해당 옵션을 빼버리거나 이름이 -`로 시작하면 기능이 꺼진다. 예를 들어, 다른 건 다 켜고 `cr-at-eol 옵션만 끄려면 아래와 같이 설정한다.

   $ git config --global core.whitespace \
    trailing-space,space-before-tab,indent-with-non-tab

   git diff 명령을 실행하면 Git은 이 설정에 따라 검사해서 컬러로 표시해준다. 그래서 좀 더 쉽게 검토해서 커밋할 수 있다. git apply 명령으로 Patch를 적용할 때도 이 설정을 이용할 수 있다. 아래처럼 명령어를 실행하면 해당 Patch가 공백문자 정책에 들어맞는지 확인할 수 있다.

   $ git apply --whitespace=warn <patch>

아니면 Git이 자동으로 고치도록 할 수 있다.

   $ git apply --whitespace=fix <patch>

이 옵션은 git rebase 명령에서도 사용할 수 있다. 공백 문제가 있는 커밋을 Upstream에 Push 하기 전에--whitespace=fix 옵션을 주고 Rebase 하면 Git은 다시 Patch를 적용하면서 공백을 설정한 대로 고친다.

서버 설정

서버 설정은 많지 않지만, 꼭 짚고 넘어가야 하는 것이 몇 개 있다.

receive.fsckObjects

Git은 Push 할 때마다 각 개체가 SHA-1 체크섬에 맞는지 잘못된 개체가 가리키고 있는지 검사하게 할 수 있다. 기본적으로 이 기능이 동작하지 않게 설정이 되어 있는데 개체를 점검하데 상당히 시간이 걸리기 때문에 Push 하는 시간이 늘어난다. 얼마나 늘어나는지는 저장소 크기와 Push 하는 양에 달렸다.receive.fsckOBjects 값을 true로 설정하면 Git이 Push 할 때마다 검증한다.

   $ git config --system receive.fsckObjects true

이렇게 설정하면 Push 할 때마다 검증하기 때문에 클라이언트는 잘못된 데이터를 Push 하지 못한다.

receive.denyNonFastForwards

이미 Push 한 커밋을 Rebase 해서 다시 Push 하지 못하게 할 수 있다. 브랜치를 Push 할 때 해당 리모트 브랜치가 가리키는 커밋이 Push 하려는 브랜치에 없을 때 Push 하지 못하게 할 수 있다. 보통은 이런 정책이 좋고 git push 명령에 -f 옵션을 주면 강제로 Push 할 수 있다.

   receive.denyNonFastForwards 옵션을 켜면 Fast-forward로 Push 할 수 없는 브랜치는 아예 Push 하지 못한다.

   $ git config --system receive.denyNonFastForwards true

사용자마다 다른 정책을 적용하고 싶으면 서버 훅을 사용해야 한다. 서버의 receive 훅으로 할 수 있고 이 훅도 이 장에서 설명한다.

receive.denyDeletes

`receive.denyNonFastForwards`와 비슷한 정책으로 `receive.denyDeletes`라는 것이 있다. 이 설정을 켜면 브랜치를 삭제하는 Push가 거절된다.

   $ git config --system receive.denyDeletes true

이제 브랜치나 Tag를 삭제하는 Push는 거절된다. 아무도 삭제할 수 없다. 리모트 브랜치를 삭제하려면 직접 손으로 server의 ref 파일을 삭제해야 한다. 그리고 사용자마다 다른 정책을 적용시키는 ACL을 만드는 방법도 있다. 이 방법은 정책 구현하기 에서 다룬다.


'Programming > Git' 카테고리의 다른 글

Git_#8.3_Git맞춤 - Git Hooks  (0) 2017.04.13
Git_#8.2_Git맞춤 - Git Attributes  (0) 2017.04.13
Git_#7.14_Git 도구 - Credential 저장소  (0) 2017.04.13
Git_#7.13_Git 도구 - Replace  (0) 2017.04.13
Git_#7.12_Git 도구 - Bundle  (0) 2017.04.13
posted by REDFORCE 2017. 4. 13. 02:19

출처 : git-scm.com


7.14 Git 도구 - Credential 저장소

Credential 저장소

SSH 프로토콜을 사용하여 리모트 저장소에 접근할 때 Passphase 없이 생성한 SSH Key를 사용하면 사용자 이름과 비밀번호를 입력하지 않고도 안전하게 데이터를 주고받을 수 있다. 반면 HTTP 프로토콜을 사용하는 경우는 매번 사용자 이름과 비밀번호를 입력해야 한다.

다행히도 Git은 이렇게 매번 인증정보(Credential)를 입력하는 경우 인증정보를 저장해두고 자동으로 입력해주는 시스템을 제공한다. Git Credential 기능이 제공하는 옵션은 아래와 같다.

  • 일단 기본적으로 아무런 설정도 하지 않으면 어떤 비밀번호도 저장하지 않는다.
    이 경우 인증이 필요한 때 매번 사용자 이름과 비밀번호를 입력해야 한다.

  • “cache” 모드로 설정하면 일정 시간 동안 메모리에 사용자 이름과 비밀번호 같은 인증정보를 기억한다. 이 정보를 Disk에 저장하지는 않으며 메모리에서도 15분 까지만 유지한다.

  • “store” 모드로 설정하면 인증정보를 Disk의 텍스트 파일로 저장하며 계속 유지한다. 계속 유지한다는 말은 리모트의 인증정보를 변경하지 않는 한 다시 인증정보를 입력하지 않아도 접근할 수 있다는 말이다. “store” 모드를 사용할 때 주의할 점은 인증정보가 사용자 홈 디렉토리 아래에 일반 텍스트 파일로 저장된다는 점이다.

  • Mac에서 Git을 사용하는 경우 “osxkeychain” 모드를 사용하면 Mac에서 제공하는 Keychain 시스템에 사용자 이름과 비밀번호를 현재 로그인 계정에 속하게 저장한다. “store” 모드하면 인증정보를 Disk에 저장하고 인증정보가 만료되지 않는 점은 같지만, Safari 브라우저가 인증정보를 저장하는 것과 같은 수준으로 암호화해서 저장한다는 점이 다르다.

  • Windows 환경에서는 “wincred” 라는 Helper가 있다. “osxkeychain” Helper와 비슷하게 동작하며 Windows Credential Store를 사용하여 안전하게 인증정보를 저장한다.

위에서 설명한 여러 모드 중 하나를 아래와 같이 설정할 수 있다.

   $ git config --global credential.helper cache

추가 옵션을 지정할 수 있는 Helper도 있다. “store” Helper는 --file <path> 옵션을 사용하여 인증정보를 저장할 텍스트 파일의 위치를 지정한다. 기본 값은 ~/.git-credentials 이다. “cache” Helper는 --timeout <seconds> 옵션을 사용하여 언제까지 인증정보를 메모리에 유지할지 설정한다. 기본 값은 “900” 초로 15분이다. 기본 경로가 아닌 다른 경로를 지정해서 인증정보를 저장하려면 아래와 같이 실행한다.

   $ git config --global credential.helper 'store --file ~/.my-credentials'

Helper를 여러개 섞어서 쓸 수도 있다. 인증이 필요한 리모트에 접근할 때 Git은 인증정보를 찾는데 Helper가 여러개 있으면 순서대로 찾는다. 반대로 인증정보를 저장할 때는 설정한 모든 모드에 저장한다. 아래 예제는 첫 번째 Path에 대해 인증정보를 읽거나 저장에 실패하면 두 번째 모드에 따라 메모리에서만 인증정보를 유지한다.

   [credential]
       helper = store --file /mnt/thumbdrive/.git-credentials
       helper = cache --timeout 30000

뚜껑을 열어보면

실제로는 어떻게 동작하는지 살펴보자. Git의 Credential-Helper 시스템의 기본 명령은 git credential이다. 이 명령이 하위 명령이나 옵션, 표준입력으로 필요한 정보를 입력받아 전달한다.

이 과정은 예제를 통해 이해하는 편이 쉽다. Credential Helper를 사용하도록 설정하고 mygithost 라는 호스트의 인증정보가 저장된 상태이다. 아래 예제는 “fill” 명령으로 Git이 특정 호스트에 대한 인증정보를 얻으려는 과정을 보여준다.

   $ git credential fill (1)
   protocol=https (2)
   host=mygithost (3)
   protocol=https (4)
   host=mygithost
   username=bob
   password=s3cre7
   $ git credential fill (5)
   protocol=https
   host=unknownhost

   Username for 'https://unknownhost': bob
   Password for 'https://bob@unknownhost':
   protocol=https
   host=unknownhost
   username=bob
   password=s3cre7
  1. 이 명령으로 인증정보를 얻어오는 과정을 시작한다.

  2. 이제 Git-credential 명령은 표준 입력으로 사용자의 입력을 기다린다. 인증정보가 필요한 대상의 프로토콜과 호스트이름을 입력한다.

  3. 빈 라인을 하나 입력하면 입력이 끝났다는 것을 의미한다. 이제 입력한 내용에 해당하는 인증정보를 응답해야 한다.

  4. Git-credential 명령이 전달받은 내용으로 인증정보를 찾아보고 찾으면 표준출력으로 찾은 정보를 응답한다.

  5. 물론 요청에 대한 인증정보가 없을 수도 있다. 이렇게 되면 Git이 사용자 이름과 비밀번호를 사용자가 입력하도록 메시지를 띄우고 값도 입력받는다. 입력받은 값을 다시 표준출력으로 응답한다.

이 Credential 시스템은 사실 Git과 분리된 독립적인 프로그램을 실행시켜 동작한다. 어떤 프로그램을 실행시킬지는 credential.helper 설정 값에 따른다. 이 설정 값을 아래와 같이 설정한다.

설정 값결과

foo

git-credential-foo 실행

foo -a --opt=bcd

git-credential-foo -a --opt=bcd 실행

/absolute/path/foo -xyz

/absolute/path/foo -xyz 실행

!f() { echo "password=s3cre7"; }; f

! 뒤의 코드를 쉘에서 실행

따라서 위에서 살펴본 여러 Helper는 사실 git-credential-cachegit-credential-store 같은 명령이다. 설정을 통해 이 명령들이 옵션이나 하위 명령을 받아서 실행하게 한다. 이 명령의 일반적인 형태는 “git-credential-foo [args] <action>” 이다. git-credential 명령과 마찬가지로 표준입력/표준출력을 프로토콜로 사용하지만 처리하는 액션(하위 명령)은 아래와 같이 다소 다르다.

  • get - 사용자 이름과 비밀번호를 요구하는 액션

  • store - Helper에서 인증정보를 저장하는 액션

  • erase - Helper에서 인증정보를 삭제하는 액션

store`나 `erase 액션은 따로 결과를 출력할 필요가 없다(Git은 결과를 무시). get 액션의 결과는 Git이 주의 깊게 관찰해서 가져다 사용하므로 매우 중요하다. Helper는 전달받은 내용으로 인증정보를 찾고 저장된 인증정보가 없다면 아무런 결과도 출력하지 않고 종료하면 된다. 적당한 인증정보를 찾았을 때는 전달받은 내용에 찾은 인증정보를 추가하여 결과로 응답한다. 결과는 몇 라인의 할당 구문으로 구성하며, Git은 이 결과를 받아서 사용한다.

아래 예제는 위에서 살펴본 예제와 같은 내용으로 git-crediential 명령 대신 git-credential-store 명령을 직접 사용한다.

   $ git credential-store --file ~/git.store store (1)
   protocol=https
   host=mygithost
   username=bob
   password=s3cre7
   $ git credential-store --file ~/git.store get (2)
   protocol=https
   host=mygithost

   username=bob (3)
   password=s3cre7
  1. git-credential-store Helper에게 인증정보를 저장하도록 한다. 저장할 인증정보는 사용자 이름은 “bob”, 비밀번호는 ‘`s3cre7’'를 저장하는데 프로토콜과 호스트가 https://mygithost 일 때 사용한다.

  2. 저장한 인증정보를 가져온다. 이미 아는 https://mygithost 리모트 주소를 호스트와 프로토콜로 나누어 표준입력으로 전달하고 한 라인을 비운다.

  3. git-credential-store 명령은 <1>에서 저장한 사용자 이름과 비밀번호를 표준출력으로 응답한다.

~/git.store 파일의 내용은 사실 아래와 같다.

   https://bob:s3cre7@mygithost

단순한 텍스트파일로 인증정보가 포함된 URL 형태로 저장한다. osxkeychain`이나 `wincred Helper를 사용하면 OS에서 제공하는 좀 더 안전한 저장소에 인증정보를 저장한다. cache Helper의 경우 나름의 형식으로 메모리에 인증정보를 저장하고 다른 프로세스에서는 (메모리의 내용을) 읽어갈 수 없다.

맞춤 Credential 캐시

`git-credential-store`나 다른 명령도 독립된 프로그램이다. 아무 스크립트나 프로그램도 Git Credential Helper가 될 수 있다. 이미 Git이 제공하는 Helper로도 충분하지만 모든 경우를 커버하지 않는다. 예를 들어 어떤 인증정보는 팀 전체가 공유해야 한다. 배포에 사용하는 인증정보가 그렇다. 이 인증정보는 공유하는 디렉토리에 저장해두고 사용한다. 이 인증정보는 자주 변경되기 때문에 로컬 Credential 저장소에 저장하지 않고 사용하고자 한다. 이런 경우라면 Git이 제공하는 Helper로는 부족하며 자신만의 맞춤 Helper가 필요하다. 맞춤 Helper는 아래와 같은 기능을 제공해야 한다.

  1. 새 맞춤 Helper가 집중해야 할 액션은 get 뿐이다. store`나 `erase 액션은 저장하는 기능이기 때문에 이 액션을 받으면 깔끔하게 바로 종료한다.

  2. 공유하는 Credential 파일은 git-credential-store 명령이 저장하는 형식과 같은 형식을 사용한다.

  3. Credential 파일의 위치는 기본 값을 사용해도 되지만 파일 경로를 넘길 수 있다.

예제로 보여주는 맞춤 Helper도 Ruby로 작성한다. 하지만, 다른 어떤 언어를 사용해도 Git이 실행할 수만 있다면 상관없다. 작성한 저장소 Helper의 소스코드는 아래와 같다.

   #!/usr/bin/env ruby

   require 'optparse'

   path = File.expand_path '~/.git-credentials' (1)
   OptionParser.new do |opts|
       opts.banner = 'USAGE: git-credential-read-only [options] <action>'
       opts.on('-f', '--file PATH', 'Specify path for backing store') do |argpath|
           path = File.expand_path argpath
       end
   end.parse!

   exit(0) unless ARGV[0].downcase == 'get' (2)
   exit(0) unless File.exists? path

   known = {} (3)
   while line = STDIN.gets
       break if line.strip == ''
       k,v = line.strip.split '=', 2
       known[k] = v
   end

   File.readlines(path).each do |fileline| (4)
       prot,user,pass,host = fileline.scan(/^(.*?):\/\/(.*?):(.*?)@(.*)$/).first
       if prot == known['protocol'] and host == known['host'] and user == known['username'] then
           puts "protocol=#{prot}"
           puts "host=#{host}"
           puts "username=#{user}"
           puts "password=#{pass}"
           exit(0)
       end
   end
  1. 우선 명령 옵션을 처리한다. 옵션으로는 Credential 파일명이 들어온다. 기본 값은 ~/.git-credentials 이다.

  2. Helper 프로그램은 get 액션만 처리하며 Credential 파일이 존재하는 경우만 처리한다.

  3. 이후에는 빈 라인이 나타날 때까지 표준입력으로부터 한 줄 한 줄 읽는다. 각 라인을 파싱하여 known 해시에 저장하고 <4>의 응답에서 사용한다.

  4. 이 루프에서 Credential 파일을 읽어서 <3>의 해시에 해당하는 정보를 찾는다. known 해시에서 프로토콜과 호스트 정보가 일치하는 경우 사용자 이름과 비밀번호를 포함하여 결과를 출력한다.

이 파일을 `git-credential-read-only`로 저장하고 `PATH`에 등록된 디렉토리 중 하나에 위치시키고 실행 권한을 부여한다. Helper를 실행하면 아래와 같다.

   $ git credential-read-only --file=/mnt/shared/creds get
   protocol=https
   host=mygithost

   protocol=https
   host=mygithost
   username=bob
   password=s3cre7

위에서 저장한 파일 이름이 “git-” 으로 시작하기 때문에 아래와 같이 간단한 이름으로 설정해서 사용할 수 있다.

   $ git config --global credential.helper 'read-only --file /mnt/shared/creds'

이렇게 살펴본 대로 Credential 저장소를 필요에 따라 맞춤 프로그램을 작성해서 확장하는 것이 어렵지 않다. 스크립트를 만들어 사용자나 팀의 가려운 부분을 긁어줄 수 있다.


'Programming > Git' 카테고리의 다른 글

Git_#8.2_Git맞춤 - Git Attributes  (0) 2017.04.13
Git_#8.1_Git맞춤 - Git 설정하기  (0) 2017.04.13
Git_#7.13_Git 도구 - Replace  (0) 2017.04.13
Git_#7.12_Git 도구 - Bundle  (0) 2017.04.13
Git_#7.11_Git 도구 - 서브모듈  (0) 2017.04.13
posted by REDFORCE 2017. 4. 13. 02:18

출처 : git-scm.com


7.13 Git 도구 - Replace

Replace

히스토리(혹은 데이터베이스)에 일단 저장한 Git의 개체는 기본적으로 변경할 수 없다. 하지만, 변경된 것처럼 보이게 하는 재밌는 기능이 숨어 있다.

Git의 replace 명령은 "어떤 개체를 읽을 때 항상 다른 개체로 보이게" 한다. 히스토리에서 어떤 커밋이 다른 커밋처럼 보이도록 할 때 이 명령이 유용하다.

예를 들어 현재 프로젝트의 히스토리가 아주 방대한 상태다. 히스토리를 둘로 나누어서 새로 시작하는 개발자에게는 히스토리를 아주 간단한 몇 개의 커밋으로 만들어서 제공하고, 프로젝트 히스토리를 분석할 사람에게는 전체 히스토리를 제공하는 상황을 생각해보자. replace 명령으로 간단해진 히스토리를 전체 히스토리의 마지막 부분에 연결해서 사용할 수 있다. 이렇게 히스토리를 변경하는 데도 커밋을 새로 쓰지 않는 매우 훌륭한 기능이다(Rebase를 생각해보면 한 부모를 변경하면 이후의 커밋은 모두 재작성된다).

위와 같은 상황을 한번 해보자. 히스토리가 어느 정도 쌓여 있는 Git 저장소를 두 저장소로 분리해서 하나는 최신 커밋 몇 개만 유지하도록 하고 다른 하나는 전체 히스토리를 유지하기로 한다. 이렇게 분리한 두 히스토리를 커밋을 재작성하지 않고 replace 명령을 사용하여 연결한다.

아래 예제로 사용하는 저장소는 히스토리에 커밋 5개가 있다.

   $ git log --oneline
   ef989d8 fifth commit
   c6e1e95 fourth commit
   9c68fdc third commit
   945704c second commit
   c1822cf first commit

예제의 히스토리를 둘로 나누어보자. 하나는 첫 번째부터 네 번째 커밋까지 히스토리로 만들어 원래의 히스토리를 그대로 유지한다. 다른 새 히스토리는 네 번째 커밋과 다섯 번째 커밋만을 포함하도록 한다.

replace1

원래의 히스토리를 유지하는 히스토리를 만들기는 쉽다. 원래 히스토리 상에 기준점을 잡아 새 브랜치를 만들고 히스토리를 유지할 리모트 저장소로 Push 하면 간단히 해결된다.

   $ git branch history c6e1e95
   $ git log --oneline --decorate
   ef989d8 (HEAD, master) fifth commit
   c6e1e95 (history) fourth commit
   9c68fdc third commit
   945704c second commit
   c1822cf first commit
replace2

history 브랜치를 새 저장소에 master 브랜치로 Push 한다.

   $ git remote add project-history https://github.com/schacon/project-history
   $ git push project-history history:master
   Counting objects: 12, done.
   Delta compression using up to 2 threads.
   Compressing objects: 100% (4/4), done.
   Writing objects: 100% (12/12), 907 bytes, done.
   Total 12 (delta 0), reused 0 (delta 0)
   Unpacking objects: 100% (12/12), done.
   To git@github.com:schacon/project-history.git
    * [new branch]      history -> master

원래 히스토리를 유지하는 히스토리를 Push 했다. 이제 남은 어려운 부분은 최신 커밋만 유지하도록 히스토리를 중간에 끊고 새로 만드는 작업이다. 새로 만든 히스토리와 원래 히스토리를 나중에 연결해서 사용할 때 네 번째 커밋을 연결하도록 작업한다. 따라서 새로 만든 히스토리는 네 번째 이후의 커밋만 유지한다.

   $ git log --oneline --decorate
   ef989d8 (HEAD, master) fifth commit
   c6e1e95 (history) fourth commit
   9c68fdc third commit
   945704c second commit
   c1822cf first commit

이런 예제 같은 경우 히스토리를 어떻게 연결하는지 설명하는 커밋을 만들어 나중에 개발자든 누구든 전체 히스토리를 볼 수 있도록 하는 것이 좋다. 이런 내용과 함께 네 번째 커밋 이전의 상태를 담을 새 커밋을 하나 만들고 네 번째 이후 커밋을 이 새 커밋 위에 Rebase 하기로 한다.

기준으로 삼을 커밋을 선택하고 새 커밋을 만든다. 예제는 9c68fdc 해시 값을 갖는 세 번째 커밋이 된다. 세 번째 커밋의 트리 내용을 기본 상태로 삼고 네 번째 이후 커밋을 히스토리에 쌓는다. commit-tree 명령을 사용해서 새 커밋을 만든다. 명령에 트리 개체를 전달하면 부모 없는 새 커밋을 생성하여 해시 값을 반환한다.

   $ echo 'get history from blah blah blah' | git commit-tree 9c68fdc^{tree}
   622e88e9cbfbacfb75b5279245b9fb38dfea10cf
Note

commit-tree 명령은 Plumbing 명령 중 하나다. 저수준 명령은 일반적으로 직접 사용할 일이 없다. 주로 사용하는 고수준 Git 명령이 하는 작업을 잘게 쪼개어 수행할 때 사용한다. 이 책에서 위의 예제처럼 특별한 작업을 위해 간혹 저수준 명령을 사용하긴 하지만 매일같이 사용하지는 않는다. 다른 여러 저수준 명령을 사용하는 예제는 Plumbing 명령과 Porcelain 명령에서 확인할 수 있다.

replace3

이제 네 번째 커밋 이후의 히스토리를 쌓을 커밋이 준비됐다. git rebase --onto 명령으로 네 번째 이후의 커밋을 새 커밋에 Rebase 한다. --onto 옵션 뒤에 전달할 커밋은 쌓아올릴 대상이 되는 커밋을 입력한다. 위에서 commit-tree 명령으로 반환받은 커밋을 사용하고 Rebase의 기준은 네 번째 커밋의 부모 커밋, 즉 세 번째 커밋인 9c68fdc 해시를 전달한다.

   $ git rebase --onto 622e88 9c68fdc
   First, rewinding head to replay your work on top of it...
   Applying: fourth commit
   Applying: fifth commit
replace4

위와 같이 Rebase 하고 나면 최신 커밋만 유지하는 새로운 히스토리가 만들어진다. 새 히스토리의 가장 첫 번째 커밋에는 어떻게 이전 히스토리를 연결해서 확인할 수 있는지 설명하는 내용이 포함되게 된다. 이렇게 생성한 새 히스토리를 새 리모트 저장소로 Push 한다. 그리고 나서 Clone 해서 히스토리를 살펴보면 가장 최근 커밋 몇 개만 보이고 가장 첫 커밋에는 히스토리를 연결하는 내용이 있게 된다.

이제 역할을 바꾸어 새 히스토리를 Clone 하고 전체 히스토리까지 확인하고자 하는 작업을 예로 들어보자. 원래 히스토리로부터 분리한 새 히스토리 위에서 원래 히스토리를 확인하려면 우선 원래 히스토리를 포함하는 리모트 저장소를 추가하고 히스토리를 Fetch 한다.

   $ git clone https://github.com/schacon/project
   $ cd project

   $ git log --oneline master
   e146b5f fifth commit
   81a708d fourth commit
   622e88e get history from blah blah blah

   $ git remote add project-history https://github.com/schacon/project-history
   $ git fetch project-history
   From https://github.com/schacon/project-history
    * [new branch]      master     -> project-history/master

위와 같이 실행하고 나면 master 브랜치에는 간단한 히스토리의 최신 커밋만 있다. 그리고 project-history/master 브랜치에는 원래 히스토리 전체가 있게 된다.

   $ git log --oneline master
   e146b5f fifth commit
   81a708d fourth commit
   622e88e get history from blah blah blah

   $ git log --oneline project-history/master
   c6e1e95 fourth commit
   9c68fdc third commit
   945704c second commit
   c1822cf first commit

이 두 히스토리를 연결하기 위해 git replace 명령을 사용하여 새 히스토리의 커밋이 원래 히스토리에 속한 커밋을 가리키도록 할 수 있다. 예제에서는 새 히스토리의 'fourth commit’과 project-history/master 브랜치의 'fourth commit’을 파라미터로 전달한다.

   $ git replace 81a708d c6e1e95

이제 master 브랜치에서 히스토리를 조회해보면 아래와 같은 히스토리가 된다.

   $ git log --oneline master
   e146b5f fifth commit
   81a708d fourth commit
   9c68fdc third commit
   945704c second commit
   c1822cf first commit

히스토리가 그럴듯하다. 연결한 네 번째 커밋 이후의 커밋을 재작성하지 않고도 replace 명령으로 간단하게 히스토리를 변경했다. 변경한 히스토리에서도 bisect`나 `blame 같은 다른 Git 명령을 사용할 수 있다.

replace5

연결된 히스토리를 보면 replace 명령으로 커밋을 변경했음에도 여전히 c6e1e95 해시가 아니라 81a708d해시로 나오는 것을 확인할 수 있다. 반면 cat-file 명령으로 보면 c6e1e95 해시의 내용이 출력된다.

   $ git cat-file -p 81a708d
   tree 7bc544cf438903b65ca9104a1e30345eee6c083d
   parent 9c68fdceee073230f19ebb8b5e7fc71b479c0252
   author Scott Chacon <schacon@gmail.com> 1268712581 -0700
   committer Scott Chacon <schacon@gmail.com> 1268712581 -0700

   fourth commit

Replace 이전 네 번째 커밋 81a708d 해시의 부모는 622e88e 해시이므로 위의 9c68fdce`로 나오는 내용은 변경한 대상인 `c6e1e95 해시의 내용이다.

이렇게 히스토리를 연결하는 것 같은 Replace 명령의 결과는 Refs로 관리한다.

   $ git for-each-ref
   e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/heads/master
   c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/remotes/history/master
   e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/HEAD
   e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/master
   c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/replace/81a708dd0e167a3f691541c7a6463343bc457040

Replace 내용을 Refs로 관리한다는 말은 손쉽게 이 내용을 서버로 Push 하여 다른 팀원과 공유할 수 있다는 것을 뜻한다. 이렇게 Replace하는 것이 유용하지 않을 수도 있다. 어쨌든 모든 팀원이 두 히스토리를 다운로드해야 하는데 굳이 나눠야 하나? 하지만, 어떨 때는 Replace하는 것이 유용할 수도 있다.


posted by REDFORCE 2017. 4. 13. 02:16

출처 : git-scm.com


7.12 Git 도구 - Bundle

Bundle

앞에서 Git 데이터를 네트워크를 거쳐 전송하는 일반적인 방법(HTTP, SSH등)을 다루었었다. 일반적으로 사용하진 않지만, 꽤 유용한 방법이 하나 더 있다.

Git에는 ‘`Bundle’'이란 것이 있다. 데이터를 한 파일에 몰아넣는 것이다. 이 방법은 다양한 경우 유용하게 사용할 수 있다. 예를 들어 네트워크가 불통인데 변경사항을 동료에게 보낼 때, 출장을 나갔는데 보안상의 이유로 로컬 네트워크에 접속하지 못할 때, 통신 인터페이스 장비가 고장났을 때, 갑자기 공용 서버에 접근하지 못할 때, 누군가에게 수정사항을 이메일로 보내야 하는데 40개 씩이나 되는 커밋을 `format-patch`로 보내고 싶지 않을 때를 예로 들 수 있다.

바로 이럴 때 git bundle`이 한 줄기 빛이 되어준다. `bundle 명령은 보통 `git push`명령으로 올려 보낼 모든 것을 감싸서 한 바이너리 파일로 만든다. 이 파일을 이메일로 보내거나 USB로 다른 사람에게 보내서 다른 저장소에 풀어서(Unbundle) 사용한다.

간단한 예제를 보자. 이 저장소에는 커밋 두 개가 있다.

$ git log
commit 9a466c572fe88b195efd356c3f2bbeccdb504102
Author: Scott Chacon <schacon@gmail.com>
Date:   Wed Mar 10 07:34:10 2010 -0800

    second commit

commit b1ec3248f39900d2a406049d762aa68e9641be25
Author: Scott Chacon <schacon@gmail.com>
Date:   Wed Mar 10 07:34:01 2010 -0800

    first commit

이 저장소를 다른 사람에게 통째로 보내고 싶은데 그 사람의 저장소에 Push 할 권한이 없거나, 그냥 Push 하고 싶지 않을 때, git bundle create 명령으로 Bundle을 만들 수 있다.

$ git bundle create repo.bundle HEAD master
Counting objects: 6, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (6/6), 441 bytes, done.
Total 6 (delta 0), reused 0 (delta 0)

이렇게 repo.bundle 이라는 이름의 파일을 생성할 수 있다. 이 파일에는 이 저장소의 master`브랜치를 다시 만드는 데 필요한 모든 정보가 다 들어 있다. `bundle 명령으로 모든 Refs를 포함하거나 Bundle에 포함할 특정 구간의 커밋을 지정할 수 있다. 이 Bundle을 다른 곳에서 Clone 하려면 위의 명령처럼 HEAD Refs를 포함해야 한다.

repo.bundle 파일을 다른 사람에게 이메일로 전송하거나 USB 드라이브에 담아서 나갈 수 있다.

혹은 repo.bundle 파일을 일할 곳으로 어떻게든 보내놓으면 이 Bundle 파일을 마치 URL에서 가져온 것처럼 Clone 해서 사용할 수 있다.

$ git clone repo.bundle repo
Cloning into 'repo'...
...
$ cd repo
$ git log --oneline
9a466c5 second commit
b1ec324 first commit

Bundle 파일에 HEAD Refs를 포함하지 않으려면 -b master 옵션을 써주거나 포함시킬 브랜치를 지정해줘야 한다. 그렇지 않으면 Git은 어떤 브랜치로 Checkout 할지 알 수 없다.

이제 새 커밋 세 개를 추가해서 채운 저장소를 다시 원래 Bundle을 만들었던 저장소로 USB든 메일이든 Bundle로 보내 새 커밋을 옮겨보자.

$ git log --oneline
71b84da last commit - second repo
c99cf5b fourth commit - second repo
7011d3d third commit - second repo
9a466c5 second commit
b1ec324 first commit

먼저 Bundle 파일에 추가시킬 커밋의 범위를 정해야 한다. 전송할 최소한의 데이터를 알아서 인식하는 네트워크 프로토콜과는 달리 Bundle 명령을 사용할 때는 수동으로 지정해야 한다. 전체 저장소를 Bundle 파일로 만들 수도 있지만, 차이점만 Bundle로 묶는 게 좋다. 예제에서는 로컬에서 만든 세 개의 커밋만 묶는다.

우선 차이점을 찾아내야 Bundle 파일을 만들 수 있다. 범위로 커밋 가리키기에서 살펴본 대로 숫자를 사용하여 커밋의 범위를 지정할 수 있다. 원래 Clone 한 브랜치인 master에는 없던 세 개의 커밋을 얻어내려면 origin/master..master 또는 master ^origin/master 파라미터를 쓰면 된다. log 명령으로 시험해볼 수 있다.

$ git log --oneline master ^origin/master
71b84da last commit - second repo
c99cf5b fourth commit - second repo
7011d3d third commit - second repo

이제 Bundle 파일에 포함할 커밋을 얻었으니 묶어보자. git bundle create 명령에 Bundle 파일의 이름과 묶어 넣을 커밋의 범위를 지정한다.

$ git bundle create commits.bundle master ^9a466c5
Counting objects: 11, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (9/9), 775 bytes, done.
Total 9 (delta 0), reused 0 (delta 0)

이제 디렉토리에 commits.bundle 파일이 생겼다. 이 파일을 동료에게 보내면 원래의 저장소에 일이 얼마나 진행되었든 간에 파일 내용을 적용할 수 있다.

이 Bundle 파일을 동료가 받았으면 원래 저장소에 적용하기 전에 무엇이 들어 있는지 살펴볼 수 있다. 우선 bundle verify 명령으로 파일이 올바른 Git Bundle인가, 제대로 적용하는 데 필요한 모든 히스토리가 현재 저장소에 있는가 확인한다.

$ git bundle verify ../commits.bundle
The bundle contains 1 ref
71b84daaf49abed142a373b6e5c59a22dc6560dc refs/heads/master
The bundle requires these 1 ref
9a466c572fe88b195efd356c3f2bbeccdb504102 second commit
../commits.bundle is okay

만약 앞에서 Bundle 파일을 만들 때 커밋 세 개로 만들지 않고 마지막 두 커밋으로만 Bundle 파일을 만들면 커밋이 모자라기 때문에 최초에 Bundle을 만들었던 저장소에 새 Bundle 파일을 합칠 수 없다. 이런 문제를 verify 명령으로 확인할 수 있다.

$ git bundle verify ../commits-bad.bundle
error: Repository lacks these prerequisite commits:
error: 7011d3d8fc200abe0ad561c011c3852a4b7bbe95 third commit - second repo

제대로 만든 Bundle 파일이라면 커밋을 가져와서 최초 저장소에 합칠 수 있다. 데이터를 가져올 Bundle 파일에 어떤 브랜치를 포함하고 있는지 살펴보려면 아래와 같은 명령으로 확인할 수 있다.

$ git bundle list-heads ../commits.bundle
71b84daaf49abed142a373b6e5c59a22dc6560dc refs/heads/master

앞에서 verify 명령을 실행했을 때도 브랜치 정보를 확인할 수 있다. 여기서 중요하게 짚을 부분은 fetch 명령이나 pull 명령으로 가져올 대상이 되는 브랜치를 Bundle 파일에서 확인하는 것이다. 예를 들어 Bundle 파일의 master 브랜치를 작업하는 저장소의 other-master 브랜치로 가져오는 명령은 아래와 같이 실행한다.

$ git fetch ../commits.bundle master:other-master
From ../commits.bundle
 * [new branch]      master     -> other-master

이런 식으로 작업하던 저장소의 master 브랜치에 어떤 작업을 했든 상관없이 Bundle 파일로부터 커밋을 독립적으로 other-master 브랜치로 가져올 수 있다.

$ git log --oneline --decorate --graph --all
* 8255d41 (HEAD, master) third commit - first repo
| * 71b84da (other-master) last commit - second repo
| * c99cf5b fourth commit - second repo
| * 7011d3d third commit - second repo
|/
* 9a466c5 second commit
* b1ec324 first commit

git bundle 명령으로 데이터를 전송할 네트워크 상황이 여의치 않거나 쉽게 공유할 수 있는 저장소를 준비하기 어려울 때도 히스토리를 쉽게 공유할 수 있다.


posted by REDFORCE 2017. 4. 13. 02:15

출처 : git-scm.com


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 모드는 일반적인 파일이나 디렉토리가 아니라 특별하다는 의미다.

서브모듈 포함한 프로젝트 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 명령 뒤에 --recursive 옵션을 붙이면 서브모듈을 자동으로 초기화하고 업데이트한다.

$ git clone --recursive 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 unnecesary 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 하는 것이다.

옵션으로 설정할 수 있는 다른 값으로 “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 또한 실패하게 된다.

서브모듈 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를 설정하는 방법은 에서 이미 다루었다. 여기에서는 서브모듈에 관련된 몇 가지 유용한 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 -fdx
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과 달리 서브모듈이 포함된 디렉토리를 망가뜨렸다 하더라도 기록해 둔 커밋 데이터는 쉽게 찾을 수 있다.

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


'Programming > Git' 카테고리의 다른 글

Git_#7.13_Git 도구 - Replace  (0) 2017.04.13
Git_#7.12_Git 도구 - Bundle  (0) 2017.04.13
Git_#7.10_Git 도구 - Git으로 버그 찾기  (0) 2017.04.13
Git_#7.9_Git 도구 - Rerere  (0) 2017.04.13
Git_#7.8_Git 도구 - 고급 Merge  (0) 2017.04.13
posted by REDFORCE 2017. 4. 13. 02:15

출처 : git-scm.com


7.10 Git 도구 - Git으로 버그 찾기

Git으로 버그 찾기

Git에는 디버깅에 사용하면 좋은 기능도 있다. Git은 굉장히 유연해서 어떤 형식의 프로젝트에나 사용할 수 있다. 문제를 일으킨 범인이나 버그를 쉽게 찾을 수 있도록 도와준다.

파일 어노테이션(Blame)

버그를 찾을 때 먼저 그 코드가 왜, 언제 추가했는지 알고 싶을 것이다. 이때는 파일 어노테이션을 활용한다. 한 줄 한 줄 마지막으로 커밋한 사람이 누구인지, 언제 마지막으로 커밋했는지 볼 수 있다. 어떤 메소드에 버그가 있으면 git blame 명령으로 그 메소드의 각 라인을 누가 언제 마지막으로 고쳤는지 찾아낼 수 있다.

$ git blame -L 12,22 simplegit.rb
^4832fe2 (Scott Chacon  2008-03-15 10:31:28 -0700 12)  def show(tree = 'master')
^4832fe2 (Scott Chacon  2008-03-15 10:31:28 -0700 13)   command("git show #{tree}")
^4832fe2 (Scott Chacon  2008-03-15 10:31:28 -0700 14)  end
^4832fe2 (Scott Chacon  2008-03-15 10:31:28 -0700 15)
9f6560e4 (Scott Chacon  2008-03-17 21:52:20 -0700 16)  def log(tree = 'master')
79eaf55d (Scott Chacon  2008-04-06 10:15:08 -0700 17)   command("git log #{tree}")
9f6560e4 (Scott Chacon  2008-03-17 21:52:20 -0700 18)  end
9f6560e4 (Scott Chacon  2008-03-17 21:52:20 -0700 19)
42cf2861 (Magnus Chacon 2008-04-13 10:45:01 -0700 20)  def blame(path)
42cf2861 (Magnus Chacon 2008-04-13 10:45:01 -0700 21)   command("git blame #{path}")
42cf2861 (Magnus Chacon 2008-04-13 10:45:01 -0700 22)  end

첫 항목은 그 라인을 마지막으로 수정한 커밋 SHA-1 값이다. 그다음 두 항목은 누가, 언제 그 라인을 커밋했는지 보여준다. 그래서 누가, 언제 커밋했는지 쉽게 찾을 수 있다. 그 뒤에 파일의 라인 번호와 내용을 보여준다. 그리고 ^4832fe2 커밋이 궁금할 텐데 이 표시가 붙어 있으면 그 커밋에서 해당 라인이 처음 커밋됐다는 것을 의미한다. 그러니까 해당 라인들은 4832fe2`에서 커밋한 후 변경된 적이 없다. 지금까지 커밋을 가리킬 때 `^ 기호의 사용법을 적어도 세 가지 이상 배웠기 때문에 약간 헷갈릴 수 있으니 어노테이션에서의 의미를 혼동하지 말자.

Git은 파일 이름을 변경한 이력을 별도로 기록해두지 않는다. 하지만, 원래 이 정보들은 각 스냅샷에 저장되고 이 정보를 이용하여 변경 이력을 만들어 낼 수 있다. 그러니까 파일에 생긴 변화는 무엇이든지 알아낼 수 있다. Git은 파일 어노테이션을 분석하여 코드들이 원래 어떤 파일에서 커밋된 것인지 찾아준다. 예를 들어 GITServerHandler.m`을 여러 개의 파일로 리팩토링했는데 그 중 한 파일이 `GITPackUpload.m이라는 파일이었다. 이 경우 -C 옵션으로 GITPackUpload.m 파일을 추적해서 각 코드가 원래 어떤 파일로 커밋된 것인지 알 수 있었다.

$ git blame -C -L 141,153 GITPackUpload.m
f344f58d GITServerHandler.m (Scott 2009-01-04 141)
f344f58d GITServerHandler.m (Scott 2009-01-04 142) - (void) gatherObjectShasFromC
f344f58d GITServerHandler.m (Scott 2009-01-04 143) {
70befddd GITServerHandler.m (Scott 2009-03-22 144)         //NSLog(@"GATHER COMMI
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 145)
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 146)         NSString *parentSha;
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 147)         GITCommit *commit = [g
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 148)
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 149)         //NSLog(@"GATHER COMMI
ad11ac80 GITPackUpload.m    (Scott 2009-03-24 150)
56ef2caf GITServerHandler.m (Scott 2009-01-05 151)         if(commit) {
56ef2caf GITServerHandler.m (Scott 2009-01-05 152)                 [refDict setOb
56ef2caf GITServerHandler.m (Scott 2009-01-05 153)

언제나 코드가 커밋될 당시의 파일이름을 알 수 있기 때문에 코드를 어떻게 리팩토링해도 추적할 수 있다. 그리고 어떤 파일에 적용해봐도 각 라인을 커밋할 당시의 파일이름을 알 수 있다. 버그를 찾을 때 정말 유용하다.

파일 어노테이션은 특정 이슈와 관련된 커밋을 찾는 데에도 좋다. 문제가 생겼을 때 의심스러운 커밋이 수십, 수백 개에 이르는 경우 도대체 어디서부터 시작해야 할지 모를 수 있다. 이때는 git bisect 명령이 유용하다. bisect 명령은 커밋 히스토리를 이진 탐색 방법으로 좁혀 주기 때문에 이슈와 관련된 커밋을 최대한 빠르게 찾아낼 수 있도록 도와준다.

코드를 운용 환경에 배포하고 난 후에 개발할 때 발견하지 못한 버그가 있다고 보고받았다. 그런데 왜 그런 현상이 발생하는지 아직 이해하지 못하는 상황을 가정해보자. 해당 이슈를 다시 만들고 작업하기 시작했는데 뭐가 잘못됐는지 알아낼 수 없다. 이럴 때 bisect 명령을 사용하여 코드를 뒤져 보는 게 좋다. 먼저 git bisect start 명령으로 이진 탐색을 시작하고 git bisect bad`를 실행하여 현재 커밋에 문제가 있다고 표시를 남기고 나서 문제가 없는 마지막 커밋을 `git bisect good [good_commit] 명령으로 표시한다.

$ git bisect start
$ git bisect bad
$ git bisect good v1.0
Bisecting: 6 revisions left to test after this
[ecb6e1bc347ccecc5f9350d878ce677feb13d3b2] error handling on repo

이 예제에서 마지막으로 괜찮았던 커밋(v1.0)과 현재 문제가 있는 커밋 사이에 있는 커밋은 전부 12개이고 Git은 그 중간에 있는 커밋을 Checkout 해준다. 여기에서 해당 이슈가 구현됐는지 테스트해보고 만약 이슈가 있으면 그 중간 커밋 이전으로 범위를 좁히고 이슈가 없으면 그 중간 커밋 이후로 범위를 좁힌다. 이슈를 발견하지 못하면 `git bisect good`으로 이슈가 아직 없음을 알리고 계속 진행한다.

$ git bisect good
Bisecting: 3 revisions left to test after this
[b047b02ea83310a70fd603dc8cd7a6cd13d15c04] secure this thing

현재 문제가 있는 커밋과 지금 테스트한 커밋 사이에서 중간에 있는 커밋이 Checkout 됐다. 다시 테스트해보고 이슈가 있으면 `git bisect bad`로 이슈가 있다고 알린다.

$ git bisect bad
Bisecting: 1 revisions left to test after this
[f71ce38690acf49c1f3c9bea38e09d82a5ce6014] drop exceptions table

이제 이슈를 처음 구현한 커밋을 찾았다. 이 SHA-1 값을 포함한 이 커밋의 정보를 확인하고 수정된 파일이 무엇인지 확인할 수 있다. 이 문제가 발생한 시점에 도대체 무슨 일이 있었는지 아래와 같이 살펴본다.

$ git bisect good
b047b02ea83310a70fd603dc8cd7a6cd13d15c04 is first bad commit
commit b047b02ea83310a70fd603dc8cd7a6cd13d15c04
Author: PJ Hyett <pjhyett@example.com>
Date:   Tue Jan 27 14:48:32 2009 -0800

    secure this thing

:040000 040000 40ee3e7821b895e52c1695092db9bdc4c61d1730
f24d3c6ebcfc639b1a3814550e62d60b8e68a8e4 M  config

이제 찾았으니까 git bisect reset 명령을 실행시켜서 이진 탐색을 시작하기 전으로 HEAD를 돌려놓는다.

$ git bisect reset

수백 개의 커밋들 중에서 버그가 만들어진 커밋을 찾는 데 몇 분밖에 걸리지 않는다. 프로젝트가 정상적으로 수행되면 0을 반환하고 문제가 있으면 1을 반환하는 스크립트를 만든다면, 이 git bisect 과정을 완전히 자동으로 수행할 수 있다. 먼저 bisect start 명령으로 이진 탐색에 사용할 범위를 알려준다. 위에서 한 것처럼 문제가 있다고 아는 커밋과 문제가 없다고 아는 커밋을 넘기면 된다.

$ git bisect start HEAD v1.0
$ git bisect run test-error.sh

문제가 생긴 첫 커밋을 찾을 때까지 Checkout 할 때마다 `test-error.sh`를 실행한다. `make`가 됐든지 `make tests`가 됐든지 어쨌든 이슈를 찾는 테스트를 실행하여 찾는다.


'Programming > Git' 카테고리의 다른 글

Git_#7.12_Git 도구 - Bundle  (0) 2017.04.13
Git_#7.11_Git 도구 - 서브모듈  (0) 2017.04.13
Git_#7.9_Git 도구 - Rerere  (0) 2017.04.13
Git_#7.8_Git 도구 - 고급 Merge  (0) 2017.04.13
Git_#7.7_Git 도구 - Reset 명확히 알고 가기  (0) 2017.04.13
posted by REDFORCE 2017. 4. 13. 02:03

출처 : git-scm.com


7.9 Git 도구 - Rerere

Rerere

git rerere 기능은 약간 숨겨진 기능이다. “reuse recorded resolution” 이라고 해서 기록한 해결책 재사용하기란 뜻의 이름이고 이름 그대로 동작한다. Git은 충돌이 났을 때 각 코드 덩어리를 어떻게 해결했는지 기록을 해 두었다가 나중에 같은 충돌이 나면 기록을 참고하여 자동으로 해결한다.

이 기능을 사용하면 재미있는 시나리오가 가능하다. 문서에서 드는 예제 중 하나는 긴 호흡의 브랜치를 깔끔하게 Merge 하고 싶은데 Merge 커밋은 많이 만들고 싶지 않을 때 사용하는 것이다. rerere 기능을 켜고 자주 Merge를 해서 충돌을 해결하고 Merge 이전으로 돌아간다. 이 과정을 반복해서 기록을 쌓아두면 rerere 기능은 나중에 한 번에 Merge 할 때 기록을 참고한다. 자동으로 충돌이 날 만한 부분을 다 해결해주시니 몸과 마음이 평안하다.

브랜치를 Rebase 할 때도 같은 전략을 사용할 수 있다. 쌓인 충돌 해결 기록을 참고하여 Git은 Rebase 할 때 발생한 충돌도 최대한 해결한다. 충돌 덩어리들을 해결하고 Merge 했는데 다시 Rebase 하기로 마음을 바꿨을 때 같은 충돌을 두 번 해결할 필요 없다.

또 다른 상황을 생각해보자. 뭔가를 개선한 토픽 브랜치가 여러 개 있을 때 이것을 테스트 브랜치에 전부 다 Merge 해야 한다. Git 프로젝트 자체에서 자주 이렇게 한다. 테스트가 실패하면 해당 Merge를 취소하고 테스트가 실패한 토픽 브랜치만 빼고 다시 Merge한다. 한 번 해결한 충돌은 다시 손으로 해결하지 않아도 된다.

rerere 기능은 간단히 아래 명령으로 설정한다.

$ git config --global rerere.enabled true

저장소에 .git/rr-cache 디렉토리를 만들어 기능을 켤 수도 있다. config 명령을 사용하는 방법이 깔끔하고 Global로 설정할 수 있다.

간단한 예제를 하나 더 살펴보자. 위에서 살펴본 예제와 비슷하다. 아래와 같은 파일 하나가 있다.

#! /usr/bin/env ruby

def hello
  puts 'hello world'
end

이전 예제와 마찬가지로 한 브랜치에서는 ‘`hello’를 ‘`hola'로 바꿨다. 그리고 다른 브랜치에서는 ``world’를 ``mundo'로 바꿨다.

rerere1

이런 상황에서 이 두 브랜치를 Merge 하면 당연히 충돌이 발생한다.

$ git merge i18n-world
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Recorded preimage for 'hello.rb'
Automatic merge failed; fix conflicts and then commit the result.

Merge 명령을 실행한 결과에 Recorded preimage for FILE 라는 결과를 눈여겨봐야 한다. 저 말이 없으면 평소처럼 그냥 충돌이 난다. 지금은 rerere 기능 때문에 몇 가지 정보를 더 출력했다. 보통은 git status 명령을 실행해서 어떤 파일에 충돌이 발생했는지 확인한다.

$ git status
# On branch master
# Unmerged paths:
#   (use "git reset HEAD <file>..." to unstage)
#   (use "git add <file>..." to mark resolution)
#
#	both modified:      hello.rb
#

git rerere status 명령으로 충돌 난 파일을 확인할 수 있다.

$ git rerere status
hello.rb

그리고 git rerere diff 명령으로 해결 중인 상태를 확인할 수 있다. 얼마나 해결했는지 비교해서 보여준다.

$ git rerere diff
--- a/hello.rb
+++ b/hello.rb
@@ -1,11 +1,11 @@
 #! /usr/bin/env ruby

 def hello
-<<<<<<<
-  puts 'hello mundo'
-=======
+<<<<<<< HEAD
   puts 'hola world'
->>>>>>>
+=======
+  puts 'hello mundo'
+>>>>>>> i18n-world
 end

rerere 기능에 포함된 것은 아니지만 ls-files -u 명령으로 이전/현재/대상 버전의 해시를 확인할 수도 있다.

$ git ls-files -u
100644 39804c942a9c1f2c03dc7c5ebcd7f3e3a6b97519 1	hello.rb
100644 a440db6e8d1fd76ad438a49025a9ad9ce746f581 2	hello.rb
100644 54336ba847c3758ab604876419607e9443848474 3	hello.rb

이제는 puts 'hola mundo' 내용으로 충돌을 해결하자. 마지막으로 rerere diff 명령을 실행하면 rerere가 기록할 내용을 확인할 수 있다.

$ git rerere diff
--- a/hello.rb
+++ b/hello.rb
@@ -1,11 +1,7 @@
 #! /usr/bin/env ruby

 def hello
-<<<<<<<
-  puts 'hello mundo'
-=======
-  puts 'hola world'
->>>>>>>
+  puts 'hola mundo'
 end

간단하게 말해서 Git은 hello.rb 파일에서 충돌이 발생했을 때 한쪽엔 ‘`hello mundo’이고 다른 한쪽에는 ‘`hola world'이면 이를 ``hola mundo’'로 해결한다.

이제 이 파일을 해결한 것으로 표시한 다음에 커밋한다.

$ git add hello.rb
$ git commit
Recorded resolution for 'hello.rb'.
[master 68e16e5] Merge branch 'i18n'

커밋을 쌓고 나면 "Recorded resolution for FILE" 이라는 메시지를 결과에서 볼 수 있다.

rerere2

이제 Merge를 되돌리고 Rebase를 해서 master 브랜치에 쌓아 보자. Reset 명확히 알고 가기에서 살펴본 대로 reset 명령을 사용하여 브랜치가 가리키는 커밋을 되돌린다.

$ git reset --hard HEAD^
HEAD is now at ad63f15 i18n the hello

이렇게 Merge 하기 이전 상태로 돌아왔다. 이제 토픽 브랜치를 Rebase 한다.

$ git checkout i18n-world
Switched to branch 'i18n-world'

$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: i18n one word
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Resolved 'hello.rb' using previous resolution.
Failed to merge in the changes.
Patch failed at 0001 i18n one word

예상대로 Merge 했을 때와 같은 충돌이 발생한다. 하지만, Rebase를 실행한 결과에 Resolved 'hello.rb' using previous resolution 메시지가 있다. 이 파일을 열어보면 이미 충돌이 해결된 것을 볼 수 있다. 파일 어디에도 충돌이 발생했다는 표시를 찾아볼 수 없다.

#! /usr/bin/env ruby

def hello
  puts 'hola mundo'
end

또 git diff 명령을 실행해보면 Git이 자동으로 해결한 결과도 확인할 수 있다.

$ git diff
diff --cc hello.rb
index a440db6,54336ba..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,7 @@@
  #! /usr/bin/env ruby

  def hello
-   puts 'hola world'
 -  puts 'hello mundo'
++  puts 'hola mundo'
  end
rerere3

checkout 명령으로 충돌이 발생한 시점의 상태로 파일 내용을 되돌릴 수도 있다.

$ git checkout --conflict=merge hello.rb
$ cat hello.rb
#! /usr/bin/env ruby

def hello
<<<<<<< ours
  puts 'hola world'
=======
  puts 'hello mundo'
>>>>>>> theirs
end

고급 Merge에서 이러한 명령을 사용하는 예제를 보았다. 이때 rerere 명령을 실행하면 충돌이 발생한 코드를 자동으로 다시 해결한다.

$ git rerere
Resolved 'hello.rb' using previous resolution.
$ cat hello.rb
#! /usr/bin/env ruby

def hello
  puts 'hola mundo'
end

강제로 충돌이 발생한 상황으로 되돌리고 rerere 명령으로 자동으로 충돌을 해결했다. 이제 충돌을 해결한 파일을 추가하고 Rebase를 완료하기만 하면 된다.

$ git add hello.rb
$ git rebase --continue
Applying: i18n one word

이처럼 여러 번 Merge 하거나, Merge 커밋을 쌓지 않으면서도 토픽 브랜치를 master 브랜치의 최신 내용으로 유지하거나, Rebase를 자주 한다면 rerere 기능을 켜두는 게 여러모로 몸과 마음에 도움이 된다.


posted by REDFORCE 2017. 4. 13. 02:00

출처 : git-scm.com


7.8 Git 도구 - 고급 Merge

고급 Merge

Git의 Merge은 진짜 가볍다. Git에서는 브랜치끼리 몇 번이고 Merge 하기가 쉽다. 오랫동안 합치지 않은 두 브랜치를 한 번에 Merge 하면 거대한 충돌이 발생한다. 조그마한 충돌을 자주 겪고 그걸 풀어나감으로써 브랜치를 최신으로 유지한다.

하지만, 가끔 까다로운 충돌도 발생한다. 다른 버전 관리 시스템과 달리 Git은 충돌이 나면 모호한 상황까지 해결하려 들지 않는다. Git의 철학은 Merge가 잘될지 아닐지 판단하는 것을 잘 하자이다. 충돌이 나도 자동으로 해결하려고 노력하지 않는다. 오랫동안 따로 유지한 두 브랜치를 Merge 하려면 몇 가지 해야 할 일이 있다.

이 절에서는 어떤 Git 명령을 사용해서 무슨 일을 해야 하는지 알아보자. 그 외에도 특수한 상황에서 사용하는 Merge 방법과 Merge를 잘 마무리하는 방법을 소개한다.

Merge 충돌

충돌의 기초에서 기초적인 Merge 충돌 해결에 대해서 다뤘다. Git은 복잡한 Merge 충돌이 났을 때 필요한 도구도 가지고 있다. 무슨 일이 일어났고 어떻게 해결하는 게 나은지 알 수 있다.

Merge 할 때는 충돌이 날 수 있어서 Merge 하기 전에 워킹 디렉토리를 깔끔히 정리하는 것이 좋다. 워킹 디렉토리에 작업하던 게 있다면 임시 브랜치에 커밋하거나 Stash 해둔다. 그래야 어떤 일이 일어나도 다시 되돌릴 수 있다. 작업 중인 파일을 저장하지 않은 채로 Merge 하면 작업했던 일부를 잃을 수도 있다.

매우 간단한 예제를 따라가 보자. 현재 'hello world’를 출력하는 Ruby 파일을 하나 가지고 있다.

#! /usr/bin/env ruby

def hello
  puts 'hello world'
end

hello()

저장소에 whitespace 브랜치를 생성하고 모든 Unix 개행을 DOS 개행으로 바꾸어 커밋한다. 파일의 모든 라인이 바뀌었지만, 공백만 바뀌었다. 그 후 “hello world” 문자열을 ‘`hello mundo’'로 바꾼 다음에 커밋한다.

$ git checkout -b whitespace
Switched to a new branch 'whitespace'

$ unix2dos hello.rb
unix2dos: converting file hello.rb to DOS format ...
$ git commit -am 'converted hello.rb to DOS'
[whitespace 3270f76] converted hello.rb to DOS
 1 file changed, 7 insertions(+), 7 deletions(-)

$ vim hello.rb
$ git diff -b
diff --git a/hello.rb b/hello.rb
index ac51efd..e85207e 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,7 +1,7 @@
 #! /usr/bin/env ruby

 def hello
-  puts 'hello world'
+  puts 'hello mundo'^M
 end

 hello()

$ git commit -am 'hello mundo change'
[whitespace 6d338d2] hello mundo change
 1 file changed, 1 insertion(+), 1 deletion(-)

master 브랜치로 다시 이동한 다음에 함수에 대한 설명을 추가한다.

$ git checkout master
Switched to branch 'master'

$ vim hello.rb
$ git diff
diff --git a/hello.rb b/hello.rb
index ac51efd..36c06c8 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,5 +1,6 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
   puts 'hello world'
 end

$ git commit -am 'document the function'
[master bec6336] document the function
 1 file changed, 1 insertion(+)

이때 whitespace 브랜치를 Merge 하면 공백변경 탓에 충돌이 난다.

$ git merge whitespace
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Automatic merge failed; fix conflicts and then commit the result.

Merge 취소하기

Merge 중에 발생한 충돌을 해결하는 방법은 몇 가지가 있다. 첫 번째는 그저 이 상황을 벗어나는 것이다. 예상하고 있던 일도 아니고 지금 당장 처리할 일도 아니라면 git merge --abort 명령으로 간단히 Merge 하기 전으로 되돌린다.

$ git status -sb
## master
UU hello.rb

$ git merge --abort

$ git status -sb
## master

git merge --abort 명령은 Merge 하기 전으로 되돌린다. 완전히 뒤로 되돌리지 못하는 유일한 경우는 Merge 전에 워킹 디렉토리에서 Stash 하지 않았거나 커밋하지 않은 파일이 존재하고 있었을 때뿐이다. 그 외에는 잘 돌아간다.

어떤 이유로든 Merge를 처음부터 다시 하고 싶다면 git reset --hard HEAD 명령으로 되돌릴 수 있다. 이 명령은 워킹 디렉토리를 그 시점으로 완전히 되돌려서 저장하지 않은 것은 사라진다는 점에 주의하자.

공백 무시하기

공백 때문에 충돌이 날 때도 있다. 단순한 상황이고 실제로 충돌난 파일을 살펴봤을 때 한 쪽의 모든 라인이 지워지고 다른 쪽에는 추가됐기 때문에 간단하다고 할 수 있다. 기본적으로 Git은 이런 모든 라인이 변경됐다고 인지하여 Merge 할 수 없다.

기본 Merge 전략은 공백의 변화는 무시하도록 하는 옵션을 주는 것이다. Merge 할 때 무수한 공백 때문에 문제가 생기면 그냥 Merge를 취소한 다음 -Xignore-all-space`나 `-Xignore-space-change 옵션을 주어 다시 Merge 한다. 첫 번째 옵션은 모든 공백을 무시하고 두 번째 옵션은 뭉쳐 있는 공백을 하나로 취급한다.

$ git merge -Xignore-space-change whitespace
Auto-merging hello.rb
Merge made by the 'recursive' strategy.
 hello.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

위 예제는 모든 공백 변경 사항을 무시하면 실제 파일은 충돌 나지 않고 모든 Merge가 잘 실행된다.

팀원 중 누군가 스페이스를 탭으로 바꾸거나 탭을 스페이스로 바꾸는 짓을 했을 때 이 옵션이 그대를 구원해 준다.

수동으로 Merge 하기

Merge 작업할 때 공백 처리 옵션을 사용하면 Git이 꽤 잘해준다. 하지만, Git이 자동으로 해결하지 못하는 때도 있다. 이럴 때는 외부 도구의 도움을 받아 해결한다. 예를 들어 Git이 자동으로 해결해주지 못하는 상황에 부닥치면 직접 손으로 해결해야 한다.

파일을 `dos2unix`로 변환하고 Merge 하면 된다. 이걸 Git에서 어떻게 하는지 살펴보자.

먼저 Merge 충돌 상태에 있다고 치자. 현 시점의 파일과 Merge 할 파일, 공통 조상의 파일이 필요하다. 이 파일들로 어쨌든 잘 Merge 되도록 수정하고 다시 Merge를 시도해야 한다.

우선 세 가지 버전의 파일을 얻는 건 쉽다. Git은 세 버전의 모든 파일에 “stages” 숫자를 붙여서 Index에 다 가지고 있다. Stage 1는 공통 조상 파일, Stage 2는 현재 개발자의 버전에 해당하는 파일, Stage 3은 `MERGE_HEAD`가 가리키는 커밋의 파일이다.

git show 명령으로 각 버전의 파일을 꺼낼 수 있다.

$ git show :1:hello.rb > hello.common.rb
$ git show :2:hello.rb > hello.ours.rb
$ git show :3:hello.rb > hello.theirs.rb

좀 더 저수준으로 파고들자면 ls-files -u 명령을 사용한다. 이 명령은 Plumbing 명령으로 각 파일을 나타내는 Git Blob의 SHA를 얻을 수 있다.

$ git ls-files -u
100755 ac51efdc3df4f4fd328d1a02ad05331d8e2c9111 1	hello.rb
100755 36c06c8752c78d2aff89571132f3bf7841a7b5c3 2	hello.rb
100755 e85207e04dfdd5eb0a1e9febbc67fd837c44a1cd 3	hello.rb

`:1:hello.rb`는 그냥 Blob SHA-1를 지칭하는 줄임말이다.

이제 워킹 디렉토리에 세 버전의 파일을 모두 가져왔다. 공백 문제를 수동으로 고친 다음에 다시 Merge 한다. Merge 할 때는 git merge-file 명령을 이용한다.

$ dos2unix hello.theirs.rb
dos2unix: converting file hello.theirs.rb to Unix format ...

$ git merge-file -p \
    hello.ours.rb hello.common.rb hello.theirs.rb > hello.rb

$ git diff -b
diff --cc hello.rb
index 36c06c8,e85207e..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,8 -1,7 +1,8 @@@
  #! /usr/bin/env ruby

 +# prints out a greeting
  def hello
-   puts 'hello world'
+   puts 'hello mundo'
  end

  hello()

이렇게 해서 멋지게 Merge가 완료된 파일을 얻었다. 사실 이것이 ignore-all-space 옵션을 사용하는 것보다 더 나은 방법이다. 왜냐면 공백을 무시하지 않고 실제로 고쳤기 때문이다. ignore-all-space 옵션을 사용한 Merge 에서는 여전히 DOS의 개행 문자가 남아서 한 파일에 두 형식의 개행문자가 뒤섞인다.

Merge 커밋을 완료하기 전에 양쪽 부모에 대해서 무엇이 바뀌었는지 확인하려면 `git diff`를 사용한다. 이 명령을 이용하면 Merge 의 결과로 워킹 디렉토리에 무엇이 바뀌었는지 알 수 있다. 한번 자세히 살펴보자.

Merge 후의 결과를 Merge 하기 전의 브랜치와 비교하려면, 다시 말해 무엇이 합쳐졌는지 알려면 git diff --ours 명령을 실행한다.

$ git diff --ours
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index 36c06c8..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -2,7 +2,7 @@

 # prints out a greeting
 def hello
-  puts 'hello world'
+  puts 'hello mundo'
 end

 hello()

위의 결과에서 Merge를 했을 때 현재 브랜치에서는 무엇을 추가했는지를 알 수 있다.

Merge 할 파일을 가져온 쪽과 비교해서 무엇이 바뀌었는지 보려면 git diff --theirs`를 실행한다. 아래 예제에서는 공백을 빼고 비교하기 위해 `-b 옵션을 같이 써주었다.

$ git diff --theirs -b
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index e85207e..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,5 +1,6 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
   puts 'hello mundo'
 end

마지막으로 `git diff --base`를 사용해서 양쪽 모두와 비교하여 바뀐 점을 알아본다.

$ git diff --base -b
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index ac51efd..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,7 +1,8 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
-  puts 'hello world'
+  puts 'hello mundo'
 end

 hello()

수동 Merge를 위해서 만들었던 각종 파일은 이제 필요 없으니 git clean 명령을 실행해서 지워준다.

$ git clean -f
Removing hello.common.rb
Removing hello.ours.rb
Removing hello.theirs.rb

충돌 파일 Checkout

앞서 살펴본 여러가지 방법으로 충돌을 해결했지만 바라던 결과가 아닐 수도 있고 심지어 결과가 잘 동작하지 않아 충돌을 직접 수동으로 더 많은 정보를 살펴보며 해결해야 하는 경우도 있다.

예제를 조금 바꿔보자. 이번 예제에서는 긴 호흡의 브랜치 두 개가 있다. 각 브랜치에는 몇 개의 커밋이 있는데 양쪽은 Merge 할 때 반드시 충돌이 날 만한 내용이 들어 있다.

$ git log --graph --oneline --decorate --all
* f1270f7 (HEAD, master) update README
* 9af9d3b add a README
* 694971d update phrase to hola world
| * e3eb223 (mundo) add more tests
| * 7cff591 add testing script
| * c3ffff1 changed text to hello mundo
|/
* b7dcc89 initial hello world code

master`에만 있는 세 개의 커밋과 `mundo 브랜치에만 존재하는 또 다른 세 개의 커밋이 있다. master브랜치에서 mundo 브랜치를 Merge 하면 충돌이 난다.

$ git merge mundo
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Automatic merge failed; fix conflicts and then commit the result.

해당 파일을 열어서 충돌이 발생한 내용을 보면 아래와 같다.

#! /usr/bin/env ruby

def hello
<<<<<<< HEAD
  puts 'hola world'
=======
  puts 'hello mundo'
>>>>>>> mundo
end

hello()

양쪽 브랜치에서 추가된 부분이 이 파일에 다 적용됐다. 적용한 커밋 중 파일의 같은 부분을 수정해서 위와 같은 충돌이 생긴다.

충돌을 해결하는 몇 가지 도구에 대해 알아보자. 어쩌면 이 충돌을 어떻게 해결해야 하는지 명확하지 않을 수도 있다. 맥락을 좀 더 살펴봐야 하는 상황 말이다.

git checkout 명령에 --conflict 옵션을 붙여 사용하는 게 좋은 방법이 될 수 있다. 이 명령은 파일을 다시 Checkout 받아서 충돌 표시된 부분을 교체한다. 충돌 난 부분은 원래의 코드로 되돌리고 다시 고쳐보려고 할 때 알맞은 도구다.

--conflict 옵션에는 diff3`나 `merge`를 넘길 수 있고 `merge`가 기본 값이다. `--conflict 옵션에 ‘diff3`를 사용하면 Git은 약간 다른 모양의 충돌 표시를 남긴다. ``ours’나 ‘`theirs'말고도 ``base’'버전의 내용까지 제공한다.

$ git checkout --conflict=diff3 hello.rb

위 명령을 실행하면 아래와 같은 결과가 나타난다.

#! /usr/bin/env ruby

def hello
<<<<<<< ours
  puts 'hola world'
||||||| base
  puts 'hello world'
=======
  puts 'hello mundo'
>>>>>>> theirs
end

hello()

이런 형태의 충돌 표시를 계속 보고 싶다면 기본으로 사용하도록 merge.conflictstyle 설정 값을 `diff3`로 설정한다.

$ git config --global merge.conflictstyle diff3

git checkout 명령도 --ours`와 `--theirs 옵션을 지원한다. 이 옵션은 Merge 하지 않고 둘 중 한쪽만을 선택할 때 사용한다.

이 옵션은 바이너리 파일이 충돌 나서 한쪽을 선택해야 하는 상황이나 한쪽 브랜치의 온전한 파일을 원할 때 사용할 수 있다. 일단 Merge 하고 나서 특정 파일만 Checkout 한 후에 커밋하는 방법도 있다.

Merge 로그

git log 명령은 충돌을 해결할 때도 도움이 된다. 로그에는 충돌을 해결할 때 도움이 될만한 정보가 있을 수 있다. 과거를 살짝 들춰보면 개발 당시에 같은 곳을 고쳐야만 했던 이유를 밝혀내는 데 도움이 된다.

“Triple Dot” 문법을 이용하면 Merge 에 사용한 양 브랜치의 모든 커밋의 목록을 얻을 수 있다. 자세한 문법은 Triple Dot를 참고한다.

$ git log --oneline --left-right HEAD...MERGE_HEAD
< f1270f7 update README
< 9af9d3b add a README
< 694971d update phrase to hola world
> e3eb223 add more tests
> 7cff591 add testing script
> c3ffff1 changed text to hello mundo

위와 같이 총 6개의 커밋을 볼 수 있다. 커밋이 어떤 브랜치에서 온 것인지 보여준다.

맥락에 따라 필요한 결과만 추려 볼 수도 있다. git log 명령에 --merge 옵션을 추가하면 충돌이 발생한 파일이 속한 커밋만 보여준다.

$ git log --oneline --left-right --merge
< 694971d update phrase to hola world
> c3ffff1 changed text to hello mundo

`--merge`대신 `-p`를 사용하면 충돌 난 파일의 변경사항만 볼 수 있다. 이건 왜 충돌이 났는지 또 이를 해결하기 위해 어떻게 해야 하는지 이해하는 데 진짜로 유용하다.

Combined Diff 형식

Merge가 성공적으로 끝난 파일은 Staging Area에 올려놓았다. 이 상태에서 충돌 난 파일들이 그대로 있을 때 git diff 명령을 실행하면 충돌 난 파일이 무엇인지 알 수 있다. 어떤 걸 더 고쳐야 하는지 아는 데에 도움이 된다.

Merge 하다가 충돌이 났을 때 git diff 명령을 실행하면 꽤 생소한 Diff 결과를 보여준다.

$ git diff
diff --cc hello.rb
index 0399cd5,59727f0..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,11 @@@
  #! /usr/bin/env ruby

  def hello
++<<<<<<< HEAD
 +  puts 'hola world'
++=======
+   puts 'hello mundo'
++>>>>>>> mundo
  end

  hello()

이런 형식을 “Combined Diff'라고 한다. 각 라인은 두 개의 컬럼으로 구분할 수 있다. 첫 번째 컬럼은 ``ours” 브랜치와 워킹 디렉토리의 차이(추가 또는 삭제)를 보여준다. 두 번째 컬럼은 ``theirs'와 워킹 디렉토리사이의 차이를 나타낸다.

이 예제에서 <<<<<<<`와 `>>>>>>> 충돌 마커 표시는 어떤 쪽에도 존재하지 않고 추가된 코드라는 것을 알 수 있다. 이 표시는 Merge 도구가 만들어낸 코드이기 때문이다. 물론 이 표시는 지워야 하는 라인이다.

충돌을 다 해결하고 git diff 명령을 다시 실행하면 아래와 같이 보여준다. 이 결과도 유용하다.

$ vim hello.rb
$ git diff
diff --cc hello.rb
index 0399cd5,59727f0..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,7 @@@
  #! /usr/bin/env ruby

  def hello
-   puts 'hola world'
 -  puts 'hello mundo'
++  puts 'hola mundo'
  end

  hello()

이 결과는 세 가지 사실을 보여준다. ‘`hola world’는 Our 브랜치에 있었지만 워킹 디렉토리에는 없다. ‘`hello mundo'는 Their 브랜치에 있었지만 워킹 디렉토리에는 없다. ``hola mundo’'는 어느 쪽 브랜치에도 없고 워킹 디렉토리에는 있다. 충돌을 해결하고 마지막으로 확인하고 나서 커밋하는 데 유용하다.

이 정보를 git log 명령을 통해서도 얻을 수 있다. Merge 후에 무엇이 어떻게 바뀌었는지 알아야 할 때 유용하다. Merge 커밋에 대해서 git show 명령을 실행하거나 git log -p`에 `--cc 옵션을 추가해도 같은 결과를 얻을 수 있다. git log -p 명령은 기본적으로 Merge 커밋이 아닌 커밋의 Patch를 출력한다.

$ git log --cc -p -1
commit 14f41939956d80b9e17bb8721354c33f8d5b5a79
Merge: f1270f7 e3eb223
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri Sep 19 18:14:49 2014 +0200

    Merge branch 'mundo'

    Conflicts:
        hello.rb

diff --cc hello.rb
index 0399cd5,59727f0..e1d0799
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,7 @@@
  #! /usr/bin/env ruby

  def hello
-   puts 'hola world'
 -  puts 'hello mundo'
++  puts 'hola mundo'
  end

  hello()

Merge 되돌리기

지금까지 Merge 하는 방법을 배웠으나 Merge 할 때 실수할 수도 있다. Git에서는 실수해도 된다. 실수해도 (대부분 간단하게) 되돌릴 수 있다.

Merge 커밋도 예외는 아니다. 토픽 브랜치에서 일을 하다가 `master`로 잘못 Merge 했다고 생각해보자. 커밋 히스토리는 아래와 같다.

우발적인 Merge 커밋.
Figure 138. 우발적인 Merge 커밋.

접근 방식은 원하는 결과에 따라 두 가지로 나눌 수 있다.

Refs 수정

실수로 생긴 Merge 커밋이 로컬 저장소에만 있을 때는 브랜치를 원하는 커밋을 가리키도록 옮기는 것이 쉽고 빠르다. 잘못 Merge 하고 나서 git reset --hard HEAD~ 명령으로 브랜치를 되돌리면 된다.

`git reset --hard HEAD~` 실행 후의 히스토리.
Figure 139. git reset --hard HEAD~ 실행 후의 히스토리.

reset`에 대해서는 이미 앞의 <<_git_reset>>에서 다뤘었기 때문에 이 내용이 그리 어렵진 않을 것이다. 간단하게 복습해보자. `reset --hard 명령은 아래의 세 단계로 수행한다.

  1. HEAD의 브랜치를 지정한 위치로 옮긴다. 이 경우엔 master 브랜치를 Merge 커밋(C6) 이전으로 되돌린다.

  2. Index를 HEAD의 내용으로 바꾼다.

  3. 워킹 디렉토리를 Index의 내용으로 바꾼다.

이 방법의 단점은 히스토리를 다시 쓴다는 것이다. 다른 사람들과 공유된 저장소에서 히스토리를 덮어쓰면 문제가 생길 수 있다. 무슨 문제가 일어나는지 알고 싶다면 Rebase 의 위험성를 참고하자. 간단히 말해 다시 쓰는 커밋이 이미 다른 사람들과 공유한 커밋이라면 reset 하지 않는 게 좋다. 이 방법은 Merge 하고 나서 다른 커밋을 생성했다면 제대로 동작하지 않는다. HEAD를 이동시키면 Merge 이후에 만든 커밋을 잃어버린다.

커밋 되돌리기

브랜치를 옮기는 것을 할 수 없는 경우는 모든 변경사항을 취소하는 새로운 커밋을 만들 수도 있다. Git에서 이 기능을 “revert” 라고 부른다. 지금의 경우엔 아래처럼 실행한다.

$ git revert -m 1 HEAD
[master b1d8379] Revert "Merge branch 'topic'"

-m 1 옵션은 부모가 보호되어야 하는 “mainline” 이라는 것을 나타낸다. HEAD`로 Merge를 했을 때(`git merge topic1) Merge 커밋은 두 개의 부모 커밋을 가진다. 첫 번째 부모 커밋은 HEAD(C6)이고 두 번째 부모 커밋은 Merge 대상 브랜치(C4)이다. 두 번째 부모 커밋(C4)에서 받아온 모든 변경사항을 되돌리고 첫 번째 부모(C6)로부터 받아온 변경사항은 남겨두고자 하는 상황이다.

변경사항을 되돌린 커밋은 히스토리에서 아래와 같이 보인다.

`git revert -m 1` 실행 후의 히스토리.
Figure 140. git revert -m 1 실행 후의 히스토리

새로 만든 커밋 ^M`은 `C6`과 내용이 완전히 똑같다. 잘못 Merge 한 커밋까지 `HEAD`의 히스토리에서 볼 수 있다는 것 말고는 Merge 하지 않은 것과 같다. `topic 브랜치를 master 브랜치에 다시 Merge 하면 Git은 아래와 같이 어리둥절해한다.

$ git merge topic
Already up-to-date.

이미 Merge 했던 topic 브랜치에는 더는 master 브랜치로 Merge 할 내용이 없다. 상황을 더 혼란스럽게 하는 경우는 `topic`에서 뭔가 더 일을 하고 다시 Merge를 하는 경우이다. Git은 Merge 이후에 새로 만들어진 커밋만 가져온다.

좋지 않은 Merge가 있는 히스토리.
Figure 141. 좋지 않은 Merge가 있는 히스토리

이러면 가장 좋은 방법은 되돌렸던 Merge 커밋을 다시 되돌리는 것이다. 이후에 추가한 내용을 새 Merge 커밋으로 만드는 게 좋다.

$ git revert ^M
[master 09f0126] Revert "Revert "Merge branch 'topic'""
$ git merge topic
되돌린 Merge를 다시 Merge 한 후의 히스토리.
Figure 142. 되돌린 Merge를 다시 Merge 한 후의 히스토리

위 예제에서는 M`과 `^M`이 상쇄됐다. `^^M`는 `C3`와 `C4`의 변경 사항을 담고 있고 `C8`은 `C7`의 내용을 훌륭하게 Merge 했다. 이리하여 현재 `topic 브랜치를 완전히 Merge 한 상태가 됐다.

다른 방식의 Merge

지금까지 두 브랜치를 평범하게 Merge 하는 방법에 대해 알아보았다. Merge는 보통 “recursive” 전략을 사용한다. 브랜치를 한 번에 Merge 하는 방법은 여러 가지다. 그 중 몇 개만 간단히 알아보자.

Our/Their 선택하기

먼저 일반적인 “recursive” 전략을 사용하는 Merge 작업을 할 때 유용한 옵션을 소개한다. 앞에서 ignore-all-space`와 `ignore-space-change 기능을 -X 옵션에 붙여 쓰는 것을 보았다. 이 -X 옵션은 충돌이 났을 때 어떤 한 쪽을 선택할 때도 사용한다.

아무 옵션도 지정하지 않고 두 브랜치를 Merge 하면 Git은 코드에 충돌 난 곳을 표시하고 해당 파일을 충돌 난 파일로 표시해준다. 충돌을 직접 해결하는 게 아니라 미리 Git에게 충돌이 났을 때 두 브랜치 중 한쪽을 선택하라고 알려줄 수 있다. merge 명령을 사용할 때 -Xours`나 `Xtheirs 옵션을 추가하면 된다.

Git에 이 옵션을 주면 충돌 표시가 남지 않는다. Merge가 가능하면 Merge 될 것이고 충돌이 나면 사용자가 명시한 쪽의 내용으로 대체한다. 바이너리 파일도 똑같다.

“hello world” 예제로 돌아가서 다시 Merge를 해보자. Merge를 하면 충돌이 나는 것을 볼 수 있다.

$ git merge mundo
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Resolved 'hello.rb' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.

하지만 -Xours`나 `-Xtheirs 옵션을 주면 충돌이 났다는 소리가 없다.

$ git merge -Xours mundo
Auto-merging hello.rb
Merge made by the 'recursive' strategy.
 hello.rb | 2 +-
 test.sh  | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)
 create mode 100644 test.sh

한쪽 파일에는 ‘`hello mundo’가 있고 다른 파일에는 ‘`hola world'가 있다. 이 Merge에서 충돌 표시를 하는 대신 간단히 ``hola world’'를 선택한다. 충돌 나지 않은 나머지는 잘 Merge 된다.

이 옵션은 git merge-file 명령에도 사용할 수 있다. 앞에서 이미 git merge-file --ours 같이 실행해서 파일을 따로따로 Merge 했다.

이런 식의 동작을 원하지만 애초에 Git이 Merge 시도조차 하지 않는 자비 없는 옵션도 있다. “ours” Merge 전략이다. 이 전략은 Recursive Merge 전략의 “ours” 옵션과는 다르다.

이 작업은 기본적으로 거짓으로 Merge 한다. 그리고 양 브랜치를 부모로 삼는 새 Merge 커밋을 만든다. 하지만, Their 브랜치는 참고하지 않는다. Our 브랜치의 코드를 그대로 사용하고 Merge 한 것처럼 기록할 뿐이다.

$ git merge -s ours mundo
Merge made by the 'ours' strategy.
$ git diff HEAD HEAD~
$

지금 있는 브랜치와 Merge 결과가 다르지 않다는 것을 알 수 있다.

이 ours 전략을 이용해 이미 Merge가 되었다고 Git을 속이고 실제로는 Merge를 나중에 수행한다. 예를 들어 release 브랜치을 만들고 여기에도 코드를 추가했다. 언젠가 이것을 master 브랜치에도 Merge 해야 하지만 아직은 하지 않았다. 그리고 master 브랜치에서 bugfix 브랜치를 만들어 버그를 수정하고 이것을 release 브랜치에도 적용(Backport)해야 한다. bugfix 브랜치를 release 브랜치로 Merge 하고 이미 포함된 master 브랜치에도 merge -s ours 명령으로 Merge 해 둔다. 이렇게 하면 나중에 release 브랜치를 Merge 할 때 버그 수정에 대한 커밋으로 충돌이 일어나지 않게끔 할 수 있다.

서브트리 Merge

서브트리 Merge 의 개념은 프로젝트 두 개가 있을 때 한 프로젝트를 다른 프로젝트의 하위 디렉토리로 매핑하여 사용하는 것이다. Merge 전략으로 서브트리(Subtree)를 사용하는 경우 Git은 매우 똑똑하게 서브트리를 찾아서 메인 프로젝트로 서브프로젝트의 내용을 Merge 한다.

한 저장소에 완전히 다른 프로젝트의 리모트 저장소를 추가하고 데이터를 가져와서 Merge 까지 하는 과정을 살펴보자.

먼저 Rack 프로젝트 현재 프로젝트에 추가한다. Rack 프로젝트의 리모트 저장소를 현재 프로젝트의 리모트로 추가하고 Rack 프로젝트의 브랜치와 히스토리를 가져와(Fetch) 확인한다.

$ git remote add rack_remote https://github.com/rack/rack
$ git fetch rack_remote --no-tags
warning: no common commits
remote: Counting objects: 3184, done.
remote: Compressing objects: 100% (1465/1465), done.
remote: Total 3184 (delta 1952), reused 2770 (delta 1675)
Receiving objects: 100% (3184/3184), 677.42 KiB | 4 KiB/s, done.
Resolving deltas: 100% (1952/1952), done.
From https://github.com/rack/rack
 * [new branch]      build      -> rack_remote/build
 * [new branch]      master     -> rack_remote/master
 * [new branch]      rack-0.4   -> rack_remote/rack-0.4
 * [new branch]      rack-0.9   -> rack_remote/rack-0.9
$ git checkout -b rack_branch rack_remote/master
Branch rack_branch set up to track remote branch refs/remotes/rack_remote/master.
Switched to a new branch "rack_branch"

(역주 - git fetch rack_remote 명령의 결과에서 warning: no common commits 메시지를 주목해야 한다.) Rack 프로젝트의 브랜치인 rack_branch`를 만들었다. 원 프로젝트는 `master 브랜치에 있다. checkout명령으로 두 브랜치를 이동하면 전혀 다른 두 프로젝트가 한 저장소에 있는 것처럼 보인다.

$ ls
AUTHORS         KNOWN-ISSUES   Rakefile      contrib         lib
COPYING         README         bin           example         test
$ git checkout master
Switched to branch "master"
$ ls
README

상당히 요상한 방식으로 Git을 활용한다. 저장소의 브랜치가 꼭 같은 프로젝트가 아닐 수도 있다. Git에서는 전혀 다른 브랜치를 쉽게 만들 수 있다. 물론 이렇게 사용하는 경우는 드물다.

Rack 프로젝트를 master 브랜치의 하위 디렉토리로 만들 수 있다. 이는 git read-tree 명령을 사용한다.read-tree 명령과 같이 저수준 명령에 관련된 많은 내용은 Git의 내부에서 다룬다. 간단히 말하자면 read-tree 명령은 어떤 브랜치로부터 루트 트리를 읽어서 현재 Staging Area나 워킹 디렉토리로 가져온다. master 브랜치로 다시 Checkout 하고 rack_branch 브랜치를 rack`이라는 `master 브랜치의 하위 디렉토리로 만들어보자.

$ git read-tree --prefix=rack/ -u rack_branch

이제 커밋하면 Rack 프로젝트의 모든 파일이 Tarball 압축파일을 풀어서 소스코드를 포함한 것 같이 커밋에 새로 추가된다. 이렇게 쉽게 한 브랜치의 내용을 다른 브랜치에 Merge 시킬 수 있다는 점이 흥미롭지 않은가? Rack 프로젝트가 업데이트되면 Pull 해서 master 브랜치도 적용할 수 있을까?

$ git checkout rack_branch
$ git pull

위의 명령을 실행하고 업데이트된 결과를 master 브랜치로 다시 Merge 한다. Recursive Merge 전략 옵션인 -Xsubtree 옵션과 --squash 옵션을 함께 사용하면 동일한 커밋 메시지로 업데이트할 수 있다. (Recursive 전략이 기본 전략이지만 설명을 위해서 사용한다)

$ git checkout master
$ git merge --squash -s recursive -Xsubtree=rack rack_branch
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

위 명령을 실행하면 Rack 프로젝트에서 변경된 모든 부분이 master 브랜치로 반영되고 커밋할 준비가 완료된다. 반대로 rack 하위 디렉토리에서 변경한 내용을 `rack_branch`로 Merge 하는 것도 가능하다. 변경한 것을 메인테이너에게 보내거나 Upstream에 Push 한다.

이런 방식은 서브모듈(서브모듈에서 자세하게 다룬다)을 사용하지 않고 서브모듈을 관리하는 또 다른 워크플로이다. 한 저장소 안에 다른 프로젝트까지 유지하면서 서브트리 Merge 전략으로 업데이트도 할 수 있다. 프로젝트에 필요한 코드를 한 저장소에서 관리할 수 있다. 다만, 이렇게 저장소를 관리하는 방법은 저장소를 다루기 좀 복잡하고 통합할 때 실수하기 쉽다. 엉뚱한 저장소로 Push 해버릴 가능성도 있다.

diff 명령으로 rack 하위 디렉토리와 rack_branch`의 차이를 볼 때도 이상하다. Merge 하기 전에 두 차이를 보고 싶어도 `diff 명령을 사용할 수 없다. 대신 git diff-tree 명령이 준비돼 있다.

$ git diff-tree -p rack_branch

혹은 rack 하위 디렉토리가 Rack 프로젝트의 리모트 저장소의 master 브랜치와 어떤 차이가 있는지 살펴보고 싶을 수도 있다. 마지막으로 Fetch 한 리모트의 master 브랜치와 비교하려면 아래와 같은 명령을 사용한다.

$ git diff-tree -p rack_remote/master


posted by REDFORCE 2017. 4. 13. 01:59

출처 : git-scm.com


7.7 Git 도구 - Reset 명확히 알고 가기

Reset 명확히 알고 가기

Git의 다른 특별한 도구를 더 살펴보기 보기 전에 `reset`과 `checkout`에 대해 이야기를 해보자. 이 두 명령은 Git을 처음 사용하는 사람을 가장 헷갈리게 하는 부분이다. 제대로 이해하고 사용할 수 없을 것으로 보일 정도로 많은 기능을 지녔다. 이해하기 쉽게 간단한 비유를 들어 설명해보자.

세 개의 트리

Git을 서로 다른 세 트리를 관리하는 컨텐츠 관리자로 생각하면 `reset`과 `checkout`을 좀 더 쉽게 이해할 수 있다. 여기서 “트리” 란 실제로는 “파일의 묶음” 이다. 자료구조의 트리가 아니다 (세 트리 중 Index는 트리도 아니지만, 이해를 쉽게 하려고 일단 트리라고 한다).

Git은 일반적으로 세 가지 트리를 관리하는 시스템이다.

트리역할

HEAD

마지막 커밋 스냅샷, 다음 커밋의 부모 커밋

Index

다음에 커밋할 스냅샷

워킹 디렉토리

샌드박스

HEAD

HEAD는 현재 브랜치를 가리키는 포인터이며, 브랜치는 브랜치에 담긴 커밋 중 가장 마지막 커밋을 가리킨다. 지금의 HEAD가 가리키는 커밋은 바로 다음 커밋의 부모가 된다. 단순하게 생각하면 HEAD는 마지막 커밋의 스냅샷이다.

HEAD가 가리키는 스냅샷을 살펴보기는 쉽다. 아래는 HEAD 스냅샷의 디렉토리 리스팅과 각 파일의 SHA-1 체크섬을 보여주는 예제다.

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

cat-file`와 `ls-tree 명령은 일상적으로는 잘 사용하지 않는 저수준 명령이다. 이런 저수준 명령을 “plumbing” 명령이라고 한다. Git이 실제로 무슨 일을 하는지 볼 때 유용하다.

Index

Index는 바로 다음에 커밋할 것들이다. 이미 앞에서 우리는 이런 개념을 ‘`Staging Area’라고 배운 바 있다. ``Staging Area'는 사용자가 git commit 명령을 실행했을 때 Git이 처리할 것들이 있는 곳이다.

먼저 Index는 워킹 디렉토리에서 마지막으로 Checkout 한 브랜치의 파일 목록과 파일 내용으로 채워진다. 이후 파일 변경작업을 하고 변경한 내용으로 Index를 업데이트 할 수 있다. 이렇게 업데이트 하고 git commit 명령을 실행하면 Index는 새 커밋으로 변환된다.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

또 다른 저수준 ls-files 명령은 훨씬 더 장막 뒤에 가려져 있는 명령으로 이를 실행하면 현재 Index가 어떤 상태인지를 확인할 수 있다.

Index는 엄밀히 말해 트리구조는 아니다. 사실 Index는 평평한 구조로 구현되어 있다. 여기에서는 쉽게 이해할 수 있도록 그냥 트리라고 설명한다.

워킹 디렉토리

마지막으로 워킹 디렉토리를 살펴보자. 위의 두 트리는 파일과 그 내용을 효율적인 형태로 .git 디렉토리에 저장한다. 하지만, 사람이 알아보기 어렵다. 워킹 디렉토리는 실제 파일로 존재한다. 바로 눈에 보이기 때문에 사용자가 편집하기 수월하다. 워킹 디렉토리는 샌드박스로 생각하자. 커밋하기 전에는 Index(Staging Area)에 올려놓고 얼마든지 변경할 수 있다.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

워크플로

Git의 주목적은 프로젝트의 스냅샷을 지속적으로 저장하는 것이다. 이 트리 세 개를 사용해 더 나은 상태로 관리한다.

reset workflow

이 과정을 시각화해보자. 파일이 하나 있는 디렉토리로 이동한다. 이걸 파일의 v1이라고 하고 파란색으로 표시한다. git init 명령을 실행하면 Git 저장소가 생기고 HEAD는 아직 없는 브랜치를 가리킨다(`master`는 아직 없다).

reset ex1

이 시점에서는 워킹 디렉토리 트리에만 데이터가 있다.

이제 파일을 커밋해보자. git add 명령으로 워킹 디렉토리의 내용을 Index로 복사한다.

reset ex2

그리고 git commit 명령을 실행한다. 그러면 Index의 내용을 스냅샷으로 영구히 저장하고 그 스냅샷을 가리키는 커밋 객체를 만든다. 그리고는 `master`가 그 커밋 객체를 가리키도록 한다.

reset ex3

이때 git status 명령을 실행하면 아무런 변경 사항이 없다고 나온다. 세 트리 모두가 같기 때문이다.

다시 파일 내용을 바꾸고 커밋해보자. 위에서 했던 것과 과정은 비슷하다. 먼저 워킹 디렉토리의 파일을 고친다. 이를 이 파일의 v2라고 하자. 이건 빨간색으로 표시한다.

reset ex4

git status 명령을 바로 실행하면 “Changes not staged for commit,” 아래에 빨간색으로 된 파일을 볼 수 있다. Index와 워킹 디렉토리가 다른 내용을 담고 있기 때문에 그렇다. git add 명령을 실행해서 변경 사항을 Index에 올려주자.

reset ex5

이 시점에서 git status 명령을 실행하면 “Changes to be committed” 아래에 파일 이름이 녹색으로 변한다. Index와 HEAD의 다른 파일들이 여기에 표시된다. 즉 다음 커밋할 것과 지금 마지막 커밋이 다르다는 말이다. 마지막으로 git commit 명령을 실행해 커밋한다.

reset ex6

이제 git status 명령을 실행하면 아무것도 출력하지 않는다. 세 개의 트리의 내용이 다시 같아졌기 때문이다.

브랜치를 바꾸거나 Clone 명령도 내부에서는 비슷한 절차를 밟는다. 브랜치를 Checkout 하면, HEAD가 새로운 브랜치를 가리키도록 바뀌고, 새로운 커밋의 스냅샷을 Index에 놓는다. 그리고 Index의 내용을 워킹 디렉토리로 복사한다.

Reset 의 역할

위의 트리 세 개를 이해하면 reset 명령이 어떻게 동작하는지 쉽게 알 수 있다.

예로 들어 file.txt 파일 하나를 수정하고 커밋한다. 이것을 세 번 반복한다. 그러면 히스토리는 아래와 같이 된다.

reset start

자 이제 reset 명령이 정확히 어떤 일을 하는지 낱낱이 파헤쳐보자. reset 명령은 이 세 트리를 간단하고 예측 가능한 방법으로 조작한다. 트리를 조작하는 동작은 세 단계 이하로 이루어진다.

1 단계: HEAD 이동

reset 명령이 하는 첫 번째 일은 HEAD 브랜치를 이동시킨다. checkout 명령처럼 HEAD가 가리키는 브랜치를 바꾸지는 않는다. HEAD는 계속 현재 브랜치를 가리키고 있고, 현재 브랜치가 가리키는 커밋을 바꾼다. HEAD가 master 브랜치를 가리키고 있다면(즉 master 브랜치를 Checkout 하고 작업하고 있다면) git reset 9e5e6a4 명령은 master 브랜치가 `9e5e6a4`를 가리키게 한다.

reset soft

reset 명령에 커밋을 넘기고 실행하면 언제나 이런 작업을 수행한다. reset --soft 옵션을 사용하면 딱 여기까지 진행하고 동작을 멈춘다.

이제 위의 다이어그램을 보고 어떤 일이 일어난 것인지 생각해보자. reset 명령은 가장 최근의 git commit 명령을 되돌린다. git commit 명령을 실행하면 Git은 새로운 커밋을 생성하고 HEAD가 가리키는 브랜치가 새로운 커밋을 가리키도록 업데이트한다. reset 명령 뒤에 HEAD~(HEAD의 부모 커밋)를 주면 Index나 워킹 디렉토리는 그대로 놔두고 브랜치가 가리키는 커밋만 이전으로 되돌린다. Index를 업데이트한 다음에 git commit 명령를 실행하면 git commit --amend 명령의 결과와 같아진다(마지막 커밋을 수정하기를 참조).

2 단계: Index 업데이트 (--mixed)

여기서 git status 명령을 실행하면 Index와 reset 명령으로 이동시킨 HEAD의 다른 점이 녹색으로 출력된다.

reset 명령은 여기서 한 발짝 더 나아가 Index를 현재 HEAD가 가리키는 스냅샷으로 업데이트할 수 있다.

reset mixed

--mixed 옵션을 주고 실행하면 reset 명령은 여기까지 하고 멈춘다. reset 명령을 실행할 때 아무 옵션도 주지 않으면 기본적으로 --mixed 옵션으로 동작한다(예제와 같이 git reset HEAD~ 처럼 명령을 실행하는 경우).

위의 다이어그램을 보고 어떤 일이 일어날지 한 번 더 생각해보자. 가리키는 대상을 가장 최근의 커밋`으로 되돌리는 것은 같다. 그러고 나서 _Staging Area_를 비우기까지 한다. `git commit 명령도 되돌리고 git add 명령까지 되돌리는 것이다.

3 단계: 워킹 디렉토리 업데이트 (--hard)

reset 명령은 세 번째로 워킹 디렉토리까지 업데이트한다. --hard 옵션을 사용하면 reset 명령은 이 단계까지 수행한다.

reset hard

이 과정은 어떻게 동작하는지 가늠해보자. reset 명령을 통해 git add`와 `git commit 명령으로 생성한 마지막 커밋을 되돌린다. 그리고 워킹 디렉토리의 내용까지도 되돌린다.

이 --hard 옵션은 매우 매우 중요하다. reset 명령을 위험하게 만드는 유일한 옵션이다. Git에는 데이터를 실제로 삭제하는 방법이 별로 없다. 이 삭제하는 방법은 그 중 하나다. reset 명령을 어떻게 사용하더라도 간단히 결과를 되돌릴 수 있다. 하지만 --hard 옵션은 되돌리는 것이 불가능하다. 이 옵션을 사용하면 워킹 디렉토리의 파일까지 강제로 덮어쓴다. 이 예제는 파일의 v3버전을 아직 Git이 커밋으로 보관하고 있기 때문에 `reflog`를 이용해서 다시 복원할 수 있다. 만약 커밋한 적 없다면 Git이 덮어쓴 데이터는 복원할 수 없다.

복습

reset 명령은 정해진 순서대로 세 개의 트리를 덮어써 나가다가 옵션에 따라 지정한 곳에서 멈춘다.

  1. HEAD가 가리키는 브랜치를 옮긴다. (--soft 옵션이 붙으면 여기까지)

  2. Index를 HEAD가 가리키는 상태로 만든다. (--hard 옵션이 붙지 않았으면 여기까지)

  3. 워킹 디렉토리를 Index의 상태로 만든다.

경로를 주고 Reset 하기

지금까지 reset 명령을 실행하는 기본 형태와 사용 방법을 살펴봤다. reset 명령을 실행할 때 경로를 지정하면 1단계를 건너뛰고 정해진 경로의 파일에만 나머지 reset 단계를 적용한다. 이는 당연한 이야기다. HEAD는 포인터인데 경로에 따라 파일별로 기준이 되는 커밋을 부분적으로 적용하는 건 불가능하다. 하지만, Index나 워킹 디렉토리는 일부분만 갱신할 수 있다. 따라서 2, 3단계는 가능하다.

예를 들어 git reset file.txt 명령을 실행한다고 가정하자. 이 형식은(커밋의 해시 값이나 브랜치도 표기하지 않고 `--soft`나 `--hard`도 표기하지 않은) `git reset --mixed HEAD file.txt`를 짧게 쓴 것이다.

  1. HEAD의 브랜치를 옮긴다. (건너뜀)

  2. Index를 HEAD가 가리키는 상태로 만든다. (여기서 멈춤)

본질적으로는 file.txt 파일을 HEAD에서 Index로 복사하는 것뿐이다.

reset path1

이 명령은 해당 파일을 Unstaged 상태로 만든다. 이 명령의 다이어그램과 git add 명령을 비교해보면 정확히 반대인 것을 알 수 있다.

reset path2

이것이 git status 명령에서 이 명령을 보여주는 이유다. 이 명령으로 파일을 Unstaged 상태로 만들 수 있다. (더 자세한 내용은 파일 상태를 Unstage로 변경하기를 참고한다.)

특정 커밋을 명시하면 Git은 “`HEAD에서 파일을 가져오는” 것이 아니라 그 커밋에서 파일을 가져온다.git reset eb43bf file.txt 명령과 같이 실행한다.

reset path3

이 명령을 실행한 것과 같은 결과를 만들려면 워킹 디렉토리의 파일을 v1으로 되돌리고 git add 명령으로 Index를 v1으로 만들고 나서 다시 워킹 디렉토리를 v3로 되돌려야 한다(결과만 같다는 얘기다). 이 상태에서 git commit 명령을 실행하면 v1으로 되돌린 파일 내용을 기록한다. 워킹 디렉토리를 사용하지 않았다.

git add 명령처럼 reset 명령도 Hunk 단위로 사용할 수 있다. --patch 옵션을 사용해서 Staging Area에서 Hunk 단위로 Unstaged 상태로 만들 수 있다. 이렇게 선택적으로 Unstaged 상태로 만들거나 내리거나 이전 버전으로 복원시킬 수 있다.

합치기(Squash)

여러 커밋을 커밋 하나로 합치는 재밌는 도구를 알아보자.

“oops.''나 ``WIP”, “forgot this file” 같은 깃털같이 가벼운 커밋들이 있다고 해보자. 이럴 때는 reset 명령으로 커밋들을 하나로 합쳐서 남들에게 똑똑한 척할 수 있다. (커밋 합치기를 하는 명령어가 따로 있지만, 여기서는 reset 명령을 쓰는 것이 더 간단할 때도 있다는 것을 보여준다.)

이런 프로젝트가 있다고 생각해보자. 첫 번째 커밋은 파일 하나를 추가했고, 두 번째 커밋은 기존 파일을 수정하고 새로운 파일도 추가했다. 세 번째 커밋은 첫 번째 파일을 다시 수정했다. 두 번째 커밋은 아직 작업 중인 커밋으로 이 커밋을 세 번째 커밋과 합치고 싶은 상황이다.

reset squash r1

git reset --soft HEAD~2 명령을 실행하여 HEAD 포인터를 이전 커밋으로 되돌릴 수 있다(히스토리에서 그대로 유지할 처음 커밋 말이다).

reset squash r2

이 상황에서 git commit 명령을 실행한다.

reset squash r3

이제 사람들에게 공개할만한 히스토리가 만들어졌다. file-a.txt 파일이 있는 v1 커밋이 하나 그대로 있고, 두 번째 커밋에는 v3버전의 file-a.txt 파일과 새로 추가된 file-b.txt 파일이 있다. v2 버전은 더는 히스토리에 없다.

Checkout

아마도 checkout 명령과 reset 명령에 어떤 차이가 있는지 궁금할 것이다. reset 명령과 마찬가지로 checkout 명령도 위의 세 트리를 조작한다. checkout 명령도 파일 경로를 쓰느냐 안 쓰느냐에 따라 동작이 다르다.

경로 없음

git checkout [branch] 명령은 git reset --hard [branch] 명령과 비슷하게 [branch] 스냅샷을 기준으로 세 트리를 조작한다. 하지만, 두 가지 사항이 다르다.

첫 번째로 reset --hard 명령과는 달리 checkout 명령은 워킹 디렉토리를 안전하게 다룬다. 저장하지 않은 것이 있는지 확인해서 날려버리지 않는다는 것을 보장한다. 사실 보기보다 좀 더 똑똑하게 동작한다. 워킹 디렉토리에서 Merge 작업을 한번 시도해보고 변경하지 않은 파일만 업데이트한다. 반면 reset --hard 명령은 확인하지 않고 단순히 모든 것을 바꿔버린다.

두 번째 중요한 차이점은 HEAD를 업데이트 하는가이다. reset 명령은 HEAD가 가리키는 브랜치를 움직이지만(브랜치 Refs를 업데이트하지만), checkout 명령은 HEAD 자체를 다른 브랜치로 옮긴다.

예를 들어 각각 다른 커밋을 가리키는 master`와 `develop 브랜치가 있고 현재 워킹 디렉토리는 develop 브랜치라고 가정해보자(즉 HEAD는 develop 브랜치를 가리킨다). git reset master 명령을 실행하면 develop 브랜치는 master 브랜치가 가리키는 커밋과 같은 커밋을 가리키게 된다. 반면 git checkout master 명령을 실행하면 develop 브랜치가 가리키는 커밋은 바뀌지 않고 HEAD가 master 브랜치를 가리키도록 업데이트된다. 이제 HEAD는 master 브랜치를 가리키게 된다.

그래서 위 두 경우 모두 HEAD는 결과적으로 A 커밋을 가리키게 되지만 방식은 완전히 다르다. reset 명령은 HEAD가 가리키는 브랜치의 포인터를 옮겼고 checkout 명령은 HEAD 자체를 옮겼다.

reset checkout

경로 있음

checkout 명령을 실행할 때 파일 경로를 줄 수도 있다. reset 명령과 비슷하게 HEAD는 움직이지 않는다. 동작은 git reset [branch] file 명령과 비슷하다. Index의 내용이 해당 커밋 버전으로 변경될 뿐만 아니라 워킹 디렉토리의 파일도 해당 커밋 버전으로 변경된다. 완전히 git reset --hard [branch] file명령의 동작이랑 같다. 워킹 디렉토리가 안전하지도 않고 HEAD도 움직이지 않는다.

git reset`이나 `git add 명령처럼 checkout 명령도 --patch 옵션을 사용해서 Hunk 단위로 되돌릴 수 있다.

요약

reset 명령이 좀 더 쉬워졌을 거라고 생각한다. 아직 checkout 명령과 정확하게 무엇이 다른지 혼란스럽거나 정확한 사용법을 다 익히지 못했을 수도 있지만 괜찮다.

아래에 어떤 명령이 어떤 트리에 영향을 주는지에 대한 요약표를 준비했다. 명령이 HEAD가 가리키는 브랜치를 움직인다면 “HEAD” 열에 ‘`REF’라고 적혀 있고 HEAD 자체가 움직인다면 ``HEAD'라고 적혀 있다.WD Safe? 열을 꼭 보자. 여기에 NO라고 적혀 있다면 워킹 디렉토리에 저장하지 않은 내용이 안전하지 않기 때문에 해당 명령을 실행하기 전에 한 번쯤 더 생각해보아야 한다.

HEADIndexWorkdirWD Safe?

Commit Level

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout [commit]

HEAD

YES

YES

YES

File Level

reset (commit) [file]

NO

YES

NO

YES

checkout (commit) [file]

NO

YES

YES

NO