프로 Git 읽는 법

Git은 서브버전보다 사용하기 어렵다. 아마 대중적인 버전관리도구 중 가장 어려울 것이다. 서브버전은 따로 공부하지 않고 썼던 개발자라도, Git을 쓰려면 공부를 해야한다.

Git을 공부하는 최고의 방법은 책 ‘프로 Git‘을 읽는 것이다. 프로 Git을 처음 읽는 순간 그것을 확신했으며, 이후 사용자로서 Git을 익히고, 부분적으로 Git을 구현해보기도 하고, 사내에 Git 교육과정을 만들어 진행해본 이후에도 그 생각은 변하지 않았다.

그러나 아무리 훌륭한 책이라고 할지라도, 오직 버전관리도구만을 위해 200페이지가 넘는 책을 처음부터 끝까지 모두 읽어야 한다는 것은 당장 개발을 시작하고 싶어하는 개발자들에게는 너무나도 지루하고 고통스러운 일이다.

다행히도 이 책을 반드시 모두가 완독해야만 하는 것은 아니다. 필요에 따라서 골라 읽어도 무방하다. 예를 들어 당장 내일부터 Git을 써서 개발을 시작해야 하는 상황이라면, 이 책의 1.3.5절과 2장을 읽고 연습해서 Git의 사용법을 익히면 된다.

어떤 챕터를 언제 읽는 것이 좋을지 적어보면 다음과 같다.

  1. 시작하기 – 가급적이면 읽자. 정 읽기 싫으면 “1.3.5 세 가지 상태”만이라도 읽자. 그것만은 절대로 읽어야 한다. 읽지 않으면 Git을 쓸 수 없다.
  2. Git의 기초 – 이것을 읽으면 Git을 쓸 수 있게 된다. 반드시 읽자
  3. Git 브랜치 – 이것을 읽으면 Git을 잘 쓸 수 있게 된다. 왠만하면 읽자.
  4. Git 서버 – Git 서버를 직접 운영한다면 읽어야 한다. 그렇지 않다면 읽지 않아도 무방하다. 물론 읽으면 서버를 이해할 수 있게 되어서 좋기는 하다.
  5. 분산 환경에서의 Git – 여러명이 협업한다면 읽는 것이 좋다.
  6. Git 도구 – 뭐 더 좋은 기능 없어? 라는 생각이 들 때 읽어도 무방하다.
  7. Git 맞춤 – 위와 마찬가지로, 이런 설정은 없나? 라는 생각이 들 때 발췌해서 읽어도 무방하다.
  8. Git과 다른 VCS – Subversion과의 차이가 궁금하다면 읽자.
  9. Git의 내부구조 – Git을 능숙하고 편안하게 다루려면 반드시 그 내부를 이해해야 한다. 팀에 한 명 정도는 이 챕터의 내용을 이해할 수 있는 수준에 달해있는 것이 좋다.

이런 훌륭한 책을 지은 스캇 샤콘과, 한국어로 번역한 박창우, 이성환, 최용재님께 깊은 감사를 드리고 싶다.

Advertisements

Git의 Staging Area는 어떤 점이 유용한가

Git에는 Staging Area라는 공간이 있다. 어떤 변경사항이 저장소에 커밋되기 전에, 반드시 거쳐야만 하는 중간단계이다.

다른 버전관리도구에는 이에 정확히 대응하는 것은 없다. 저장소가 추적하는(관심의 대상이 되는) 파일들의 목록을 유지하고, 그 파일들에 대한 메타데이터를 관리하는 것은 다른 저장소들도 하는 일이지만, Git 처럼 커밋될 예정인 파일의 내용들까지 기억하지는 않는다.

이 Staging Area의 존재는 처음 Git을 사용하는 입장에서는 그저 불편만 안겨주고 이해만 더디게 만들어주는 목적불명의 무언가에 지나지 않는다. 다른 SCM에선 파일을 고치고 바로 커밋하면 되었는데, Git은 반드시 그 전에 add를 해 줘야 한다. 이런 비용을 감수하면서까지 Git이 Staging Area라는 공간을 사용자들에게 노출시키고 있는 이유는 무엇일까. 어떤 상황에서 Staging Area가 유용한지 살펴보면서 이해해보자.

일부분만 커밋할 때

Working directory에서 코드를 고칠 때는 자유롭게 고치고 싶다. 하지만 커밋할때는 atomic하게 하고 싶다. 이를 가능하게 하려면 커밋할 때 working directory 전체가 아니라 부분만 커밋하면 될 것이다.

staging area라는 것이 없다고 가정해보자. ‘커밋될 예정인 내용들’을 디스크에 저장해 둘 공간이 없으므로, 커밋할 때 어느 부분을 커밋할지 한번에 결정해야 한다. 한번에 어렵지 않게 결정할 수도 있지만 시간을 들여 생각해야 할 수도 있다. 시간을 들여 조금씩 커밋할 부분을 결정해 나간다고 하면, 그 시간동엔 나의 결정사항들은 메모리상에 존재해야 한다. 메모리에 올라와있는 정보는 디스크에 저장되어 있는 정보보다 다루기 까다롭다. Staging Area에 저장되어있는 ‘곧 커밋될 예정인 내용’들은 쉽게 git diff --staged등의 명령으로 확인할 수 있겠지만, 이것이 메모리에 올라와있는 상태라면, 해당 메모리 영역을 사용하고 있는 프로세스를 찾아서 물어봐야 알 수 있다. 실수로 그 프로세스를 죽였다면 공들여 커밋을 준비하던 것이 허사가 된다. (( 커밋을 깔끔하게 만드는데 과도하게 공을 들인다는 생각이 드는가? 하지만 이런 개발자들이 실제로 있다. ))

충돌을 해결할 때

충돌을 해결해야 하는 상황에서도 비슷한 문제가 있다. 5개의 파일을 머지했는데 그 중 2개에서 충돌이 발생했다고 해 보자. 3개의 파일은 그냥 커밋하면 되고 2개는 충돌을 해소(resolve)해줘야한다. 여기서도 위와 같은 문제가 생긴다. 큰 규모의 충돌이 발생한 경우 이를 해결하는 것은 상당히 시간이 들어가는 일이고, 이를 위한 작업 데이터를 메모리에만 올려놓는 것은 참으로 불안하다. 따라서 이 정보 역시 Staging Area와 같이 디스크의 어떤 공간에 저장해두는 것이 훨씬 좋을 것이다. 이것은 Staging Area가 없는 Subversion이라도 마찬가지다. 서브버전은 어떤 파일이 충돌했는지 ‘파일 상태’를 기억한다. 그러나 이보다는 Git의 방식이 더 낫다. 서브버전은 파일 단위로 충돌 여부를 기억하기 때문에, 파일의 일부분만 충돌을 해소한 후 커밋하는 것은 매우 어렵기 때문이다. Git이라면 간단하다. 충돌이 발생한 파일에서, 필요한 만큼만 충돌을 해소하고 그 부분만 add하여 커밋하면 된다.

커밋 다시하기

commit --amend 명령으로 커밋을 다시 할 때도 Staging Area의 존재는 빛을 발한다. 커밋을 다시 할 때 로그메시지만 고치는 것이 아니라 파일들도 좀 고치고 싶다면, commit --amend 전에 파일을 고쳐서 Staging Area에 add하기만 하면 된다. Staging Area가 없었다면 commit --amend시에 Git에게 다른 방법으로 패치를 전달해주었어야 했을 것이다.

요약

Staging Area는 단순하지만 유용한 저장공간이다. 많은 Git의 명령들이 Staging Area를 활용하여 Git을 더욱 생산적인 도구로 만들어준다.

물론 Staging Area라는 생소한 개념이 Git의 진입장벽을 더욱 높이는 것은 사실이지만, 그럼에도 불구하고 나를 비롯한 Git을 즐겨쓰는 많은 개발자들에게 Staging Area는 여전히 필수불가결한 존재이다. 누군가 이해하기 쉬우면서도 현 Staging Area에 대한 요구를 흡수할(혹은 불필요하게 만들) 수 있는 어떤 개념을 만들어낸다면 몰라도 그 전까지는 Git의 핵심 기능이자 특징으로서의 지위가 변하는 일은 없을 것이다.

관련글

며칠전에 Git 메일링리스트에 Staging Area가 왜 필요한지에 대해 질문을 했었는데, 많은 분들이 회신을 주셨다. 그 회신들을 통해 알게 된, Staging Area의 이점에 대해 설명하는 두 개의 글을 소개한다.

git의 revision에 대해

git에서 revision이란 특정 Git object를 가리킬 수 있는 표현식을 의미한다. HEAD~2, master, a8dd808db6d87a1d809b1a223e08ab69602b2d3a, HEAD:test.txt 등이 모두 “revision”이다.

문법

ABNF 로 표현해보면 대략 다음과 같다.

DIGIT = "0" / "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9"
sha1 = 1*40HEXDIGIT
refname = name / "refs" "/" (name /
          ("tags" "/" name) /
          ("heads" "/" name) /
          ("remotes" "/" name) /
          ("remotes" "/" name "/" HEAD))
specifier = "@" "{" (date / num) "}"
rev = sha1 / refname [specifier] / specifier /
      rev ("^" / "~" / "^" DIGIT / "~" DIGIT)
revision = rev /  ":" "/" regexp / ":" ("0" / "1" / "2" / "3") ":" path

그닥 잘 알려져 있지는 않지만 간혹 유용한 몇가지 표현을 소개해 보겠다.

@{…}

@을 이용해, 특정 refname(브랜치 등)이 과거에 가리켰던 커밋을 지칭할 수 있다. 쉽게 말해서 git reflog에 나열되는 특정 커밋을 지칭할 수 있다.

  • <refname>@{n} – refname이 과거에 가리켰던 커밋. git reflog 해보면 무슨 의미인지 알 수 있을 것이다. master@{5} 와 같은 표현이 가능하다.
  • <refname>@{date} – 특정 날짜, 시각에서 refname이 가리켰던 커밋. master@{yesterday} 식의 표현이 가능하다.

:n:path

:n:path 표현식으로, 주어진 path에 대해 특정 상태에서의 tree 혹은 blob을 지칭할 수 있다.

  • :0:path – staging area

머지중인 상태에서는 공통 조상이나 특정 브랜치의 path에 대응하는 tree나 blob을 지칭할 수 있다.

  • :1:path – 공통 조상(common ancestor)
  • :2:path – 현재 브랜치
  • :3:path – 현재 브랜치에 머지되어지는 중인 브랜치

더 자세히

man gitrevisions로 더 자세한 설명을 볼 수 있다.

git에서 특정 파일만 남기기 (git 기반 위키에서 특정 파일만 공개하기)

코드저장소에서 실수로 절대 넣어서는 안되는 파일을 넣어 버렸을 때, git의 filter-branch 명령을 사용하면 간단하게 특정 파일만 제외시키고 전체 히스토리를 재구축하는 것이 가능하다.

그러나 반대로 특정 파일만 남기는 것은 조금 까다롭다. filter-branch를 응용해서 남기고자 하는 파일 외에는 모두 삭제하면서 히스토리 전체를 재구축하는 방법이 있긴 한데, 속도도 느리고 잘 동작하게 만들기도 쉽지가 않다.

사실 일반적인 소프트웨어 개발 프로젝트라면, 프로젝트에서 특정 파일만 남기겠다는 요구사항은 그렇게 흔하지 않을 것이다. 하지만 git은 소프트웨어 개발 프로젝트에서만 쓰이는 것이 아니다. 개발자노트골룸과 같은 위키시스템은 git 저장소에 위키페이지를 저장한다. 그리고 위키 사용자들은 개인위키로 관리하던 페이지에서 몇몇 페이지만 선별하여 공개적으로 publish하고 싶어한다.

git은 rcs나 cvs와는 달리 파일별로 버전관리를 하지 않기 때문에 이런 요구에 부응해주기가 참 어렵다. 하지만 불가능하지는 않다.

이런 경우 보통은 fiter-branch를 써서 불필요한 파일들을 제거하는 해법을 선택하겠지만, 여기서는 그보다 더 빠르고 보다 잘 동작하는 방법을 알아볼 것이다.

branch 만들기 및 checkout

filter-branch와 다른 방법인 것 처럼 이야기했지만, 사실 기본적인 접근은 같다. 히스토리를 바닥부터 쌓아가는 것이다. 우선 완전히 아무 커밋도 없는 브랜치 “public”를 만든다. –orphan 옵션을 사용하면 가능하다.

git checkout --orphan "public"

준비

그리고 이제부터 커밋을 하나하나 쌓아갈 것이니, 불필요하게 staging area에 파일이 남아있다면 모두 제거해버리자.

git rm --cached -r .

commit

그럼 이제부터가 진짜다. 이제 공개하고자 하는 파일에 대한 히스토리를 죽 뽑아서, 그에 대한 커밋만 커밋할 것이다.

다음과 같은 방법으로 특정 파일들에 대한 커밋의 목록을 얻는다.

filenames=file1 file2 file3
commits=`git log --reverse --format='%H' master -- $filenames`

--foramt='%H'를 주면 커밋로그 출력 옵션이 커밋 아이디만 출력하는 형태가 될 것이다. --reverse 옵션을 주는 이유는, 커밋 로그를 가장 오래된 것이 먼저 오게 출력하기 위함이다. 그 순서대로 커밋을 할 것이기 때문이다.

위에서 얻은 커밋들을 하나하나 커밋한다. 그 커밋들에서도 오직 공개하기를 원하는 파일만 뽑아내어 커밋한다.

for commit in $commits
do
git checkout -qf $commit -- $filenames
git add --ignore-errors $filenames
git commit -C $commit
done

push

마지막으로 이렇게 구축한 브랜치 “public” 를 원격저장소에 push하면, 내가 선정한 파일들만 push될 것이다. 아마도 이 push는 히스토리 전체를 뒤바꿀 것이므로, -f 옵션을 줘야만 할 것이다.

아래는 개발자노트에 공개하고자 하는 페이지들만 담긴 브랜치 “public”을 push하는 예이다.

git push -f http://npcode.com:3000/devnote/notes/note.git public:master

마무리

“publish” 브랜치는 더 이상 필요치 않으니 제거한다. 다음번에 또 publish를 하기 위함이다.

git branch -D "publish"

전체 소스코드

전체 소스코드는 아래와 같다. argument로 공개할 파일들을 지정해주면 알아서 브랜칭하고, 히스토리를 재구축하고, “public”이란 이름의 저장소로 push한 뒤, 그 branch를 삭제하기까지 해주는 쉘 스크립트이다.

filenames=$*
from=master
commits=`git log --reverse --format='%H' $from -- $filenames`
branch="pub-`date +'%y%m%d-%H%M%S'`"
git checkout --orphan "$branch"
git rm --cached -r .
for commit in $commits
do
git checkout -qf $commit -- $filenames
git add --ignore-errors $filenames
git commit -C $commit > /dev/null
done
git push -f public "$branch":master
git branch -D $branch

사용은 다음과 같이 하면 된다. 아래와 같이 실행하면 “git”, “linux”, “mac” 페이지만 공개하게 된다.

./pub.sh git linux mac

Git의 blob은 어떻게 발음하는가.

Git 메일링 리스트에 문의해 본 결과, 일반명사 blob과 같이 블랍으로 발음하는 것이 맞다고 한다.

사실 이걸 어떻게 발음하는 것이 맞는가에 대해서는 어디에도 명시되어 있지 않다. gitglossary에도 없다.

일반적으로 database에서는 blob이 binary large object라는 의미로 쓰이고, 비랍(bee-lab)으로 발음하지만 Git에서는 그런 의미가 아닌지도 모르겠다.

단 두 명이 답을 주었을 뿐이긴 하지만, 이 메일링 리스트는 Git 개발자와 사용자가 모두 활발히 참여하는 메일링 리스트이고, 이견을 말하는 사람이 없는 것으로 보아 맞다고 봐도 무방할 것이다.

디스크 공간 절약을 위해 파일을 Git 저장소에 보관하기

SSD를 쓰기 시작하면서 디스크 공간이 굉장히 소중해졌다. 그래서 잘 안 쓰는 파일들은 zip으로 압축해서 저장하곤 하지만 뭔가 파일 하나가 보고 싶을 때는 꺼내기가 좀 불편하다.

대안은 Git bare 저장소로 보관하는 것이다. 이 방법은 공간도 적게 사용하면서 필요할 때 gut 명령으로 간단히 파일을 꺼내 볼 수도 있다.

뿐만 아니라 Git 저장소는 굉장히 공간효율적이다. 때때로 gzip으로 압축했을 때 보다도 디스크 공간을 적게 사용하는 경우도 있어 나를 놀라게 한다.

Git 저장소로 파일을 보관하고 읽고 쓰는 법에 대해 간단히 알아보자.

Git 저장소에 넣기

평소에 문서파일을 저장하던 text라는 디렉토리를 Git 저장소로 바꾸고 싶어졌다면 다음과 같이 하면 된다.

cd text
git init
git add *
git commit -m 'put them all'
mv .git ../text.git
cd ..
rm -rf text
cd text.git
git config core.bare true

Git 저장소의 파일 다루기

위에서 git 명령으로 간단히 파일을 꺼내 볼 수 있다고 말하긴 했지만, 사실 bare 저장소에 들어있는 파일을 직접 다루는 것은 흔치 않은 일이다. 그래서 익숙해지기 어렵다. 그러므로 Git bare 저장소에서 동작하는 cp나 cat과 같은 coreutils를 간단히 만들어서 쓰는 것이 편하다.

git-cat

우선 git-cat을 만들어보자. 아주 간단하다.

#!/bin/sh
git show HEAD:$1

이 파일을 실행 가능하게 설정하고, path가 잡혀있는 디렉토리에 넣어두면 그 다음부터는 git cat <filename>으로 git 저장소에 들어있는 파일 내용을 볼 수 있게 된다. 예를 들면 다음과 같다.

$ cd text.git
~/text.git$ git cat hello.txt
Hello, World

git-ls

git-cat은 GNU coreutils의 cat과는 달리 디렉토리의 내용도 볼 수 있다. 하지만 좀 더 ls에 가깝게 출력하는 기능이 필요하다면 다음과 같은 스크립트를 만들면 된다.

#!/bin/sh
git ls-tree --name-only HEAD

알려진 이슈: 한글 파일명을 제대로 보여주지 못한다는 문제가 있다. “\355\225\234\352\270\200” 와 같이 코드로 보여준다.

git-cp

마지막으로 저장소 밖의 파일을 저장소 안으로 넣을 수 있는 git-cp다. 이건 파이썬으로 작성했다. 전체 소스코드는 여기에 있는데, 상당히 긴 관계로 가장 중요한 세 군데만 설명하겠다.

우선 git hash-object로 blob을 만든다. (do()는 쉘명령 실행하는 함수) blob은 파일 하나에 해당한다.

blob_id = do(git_cmd + ['hash-object', '-t', 'blob', '-w', '--', src],
        None, options).strip()

그리고 git mktreee로 위에서 만든 blob이 담긴 tree를 만든다. tree는 디렉토리에 해당한다.

tree_id = do(git_cmd + ['mktree', '-z'], None, options, ''.join(files)).strip()

마지막으로 git commit-tree로 커밋을 하면 된다.

commit_id = do(git_cmd + ['commit-tree', tree_id] + parent_param +
        ['-m', message], None, options).strip()

사용법은 다음과 같다.

$ git cp hello.txt text.git

알려진 이슈: 커밋을 한번도 안한 빈 코드저장소에 파일을 복사하려 시도하면 오동작한다.

전체 소스코드

git-coreutils에 전체 코드를 올려 놓았다.

git bisect 응용 – 줄바꿈 문자 잘못 넣은 사람 찾기

나는 언제나 줄바꿈 문자를 ‘\n’로 통일한다. 내가 참여하는 모든 프로젝트에서도 그러하다.

그러나 윈도 사용자들은 기본적으로 ‘\r\n’을 사용하도록 되어있으므로, IDE나 편집기에서 혹은 Git에서 설정을 변경해주지 않는 이상 줄바꿈 문자가 ‘\r\n’이 된다. 이로 인해 다양한 OS를 사용하는 개발자들이 함께 같은 프로젝트에서 개발을 하다보면 내가 작성한 코드에 누군가 ‘\r\n’을 집어넣어 심란해지는 일이 종종 있다. 누가 집어넣었는지 찾아내고 싶지만 (( 물론 누군지 찾아내서 그의 편집기나 Git 설정을 올바르게 바로잡아주려는 것이다. )) 이미 들어간지 며칠 지나서 커밋이 수십개나 쌓였다면 찾아내기가 쉽지는 않다.

작년에 이런 문제를 실제로 자주 겪었었고, 지난번 포스팅에서 소개했던 git bisect를 이용해 해결했었다.

우선 아래와 같이 간단히 \r\n을 검출하는 파이썬 스크립트 eol.py 를 만들었다.

#/bin/env python
import sys

if sys.stdin.readlines()[0][-2:] == 'rn':
    exit(1)

그리고 위의 스크립트를 이용해 원하는 파일에 대해 줄바꿈 문자를 검사하는 쉘스크립트 test.sh를 만들었다. (( 이걸 굳이 두 개의 스크립트로 쪼갠 것은, 난 기본적으로 파이썬이 익숙해서 왠만하면 파이썬으로 만들긴 하지만, 이 사례에서는 혹시 대상 파일이 옮겨졌더라도 잘 동작하게 하기 위해 쉘의 **이 필요했기 때문이다. ))

#!/bin/sh
cat **/RepositoryController.java | python eol.py
exit $?

그럼 이제 test.sh 를 실행하면 내가 검사하고자 하는 RepositoryController.java 파일에 eol 문자가 ‘\r\n’인 경우에는 1, 아니면 0이 반환될 것이다.

이제 아래와 같이 실행하면 ‘\r\n’이 처음으로 잘못 들어간 커밋이 무엇인지 알려준다. 엔 문제가 없었던(‘\r\n’이 없었던) 커밋을 적어주면 된다.

git bisect bad
git bisect good <good-commit>
git bisect run sh test.sh

ps. git log -G 로 간단히 잘못된 줄바꿈 문자를 찾아내는 것도 가능하지 않을까 싶은데 방법을 잘 모르겠다.