애플리케이션에 Git 넣기

개발자가 사용하는 애플리케이션은 버전 관리 도구와 통합될 필요가 있다. 꼭 개발자가 아니더라도 문서 편집기 같은 프로그램에서 버전 관리가 되면 매우 좋다. Git은 매우 다양한 시나리오를 잘 지원한다.

Git을 지원하는 애플리케이션을 만들 때는 세 가지 방법의 하나를 선택할 수 있다. 쉘 명령어를 실행시키거나 Libgit2를 사용하거나 JGit을 사용한다.

Git 명령어

쉘 프로세스를 띄우고(Spawn) Git 명령어를 실행하는 방법이 있다. 이게 가장 표준적인 방법으로 Git의 모든 기능을 사용할 수 있다. 웬만한 환경에서는 명령어를 프로세스로 실행하는 것은 간단하므로 이 방법은 사용하기 쉬운 편이다. 그러나 이 방법은 몇 가지 제약사항이 있다.

첫째는 결과가 텍스트로 출력된다. Git이 상황에 따라 다르게 출력하는 결과를 파싱해야 한다. 진행상태와 결과 정보를 구분해서 잘 읽어야 해서 어렵고 에러 나기 쉽다.

둘째는 에러 처리가 어렵다. 저장소가 깨져 있거나 사용자가 잘못 설정했을 때 Git은 그냥 제대로 실행되지 않을 뿐이다.

마지막 결점은 프로세스를 관리해야 한다는 점이다. 별도의 프로세스로 Git을 실행하기 때문에 애플리케이션에 불필요한 복잡성이 추가된다. 여러 프로세스를 조종하는 일은 지뢰밭이라 할 수 있다. 특히 동시에 여러 프로세스가 한 저장소에 접근하면 !@#$%^&*되기 쉽다.

Libgit2

다른 방법으로는 Libgit2 라이브러리가 있다. Libgit2는 Git에 의존하지 않는다. 일반 프로그램에서 사용하기 좋게 API를 설계했다. http://libgit2.github.com에서 내려받을 수 있다.

먼저 API가 어떻게 생겼는지 구경해보자.

// Open a repository
git_repository *repo;
int error = git_repository_open(&repo, "/path/to/repository");

// Dereference HEAD to a commit
git_object *head_commit;
error = git_revparse_single(&head_commit, repo, "HEAD^{commit}");
git_commit *commit = (git_commit*)head_commit;

// Print some of the commit's properties
printf("%s", git_commit_message(commit));
const git_signature *author = git_commit_author(commit);
printf("%s <%s>\n", author->name, author->email);
const git_oid *tree_id = git_commit_tree_id(commit);

// Cleanup
git_commit_free(commit);
git_repository_free(repo);

첫 두 라인은 Git 저장소를 여는 코드다. git_repository 타입은 메모리에 있는 저장소 정보에 대한 핸들을 나타낸다. git_repository_open 메소드는 워킹 디렉토리나 .git 폴더 경로를 알 때 사용한다. 저장소 경로를 정확히 모를 때는 git_repository_open_ext 메소드로 찾는다. git_clone 메소드와 관련된 메소드는 원격에 있는 저장소를 로컬에 Clone 할 때 사용한다. 그리고 git_repository_init은 저장소를 새로 만들 때 사용한다.

rev-parse 문법을 사용하는 두 번째 코드는 HEAD가 가리키는 커밋을 가져온다. git_object 포인터는 Git 개체 데이터베이스에 있는 개체를 가리킨다. git_object는 몇 가지 “자식” 타입의 “부모” 타입이다. 이 “자식” 타입들은 git_object에 해당하는 부분에 대해서는 메모리 구조가 같다. 그래서 맞는 자식이라면 이렇게 캐스팅해도 안전하다. git_object_type(commit)처럼 호출하면 GIT_OBJ_COMMIT을 리턴한다. 그래서 git_commit 포인터로 캐스팅해도 된다.

그다음 블록은 커밋 정보를 읽는 코드다. 마지막 라인의 git_oid는 Libgit2에서 SHA-1 값을 나타내는 타입이다

이 예제를 보면 몇 가지 코딩 패턴을 알 수 있다.

마지막 라인을 이유로 Libgit2를 C에서 사용할 가능성은 매우 낮다. 다양한 언어나 환경에서 사용할 수 있는 Libgit2 바인딩이 있어서 Git 저장소를 쉽게 다룰 수 있다. Rugged라는 Ruby 바인딩을 사용해서 위의 예제를 재작성해 보자. Rugged에 대한 자세한 정보는 https://github.com/libgit2/rugged에 있다.

repo = Rugged::Repository.new('path/to/repository')
commit = repo.head.target
puts commit.message
puts "#{commit.author[:name]} <#{commit.author[:email]}>"
tree = commit.tree

비교해보면 코드가 더 간결해졌다. Rugged는 예외를 사용해서 더 간결하다. 하지만 ConfigErrorObjectError 같은 에러가 발생할 수 있다. 그리고 Ruby는 가비지 콜렉션을 사용하는 언어라서 리소스를 해제하지 않아도 된다. 좀 더 복잡한 예제를 살펴보자. 새로 커밋하는 예제다.

blob_id = repo.write("Blob contents", :blob) 1

index = repo.index
index.read_tree(repo.head.target.tree)
index.add(:path => 'newfile.txt', :oid => blob_id) 2

sig = {
    :email => "bob@example.com",
    :name => "Bob User",
    :time => Time.now,
}

commit_id = Rugged::Commit.create(repo,
    :tree => index.write_tree(repo), 3
    :author => sig,
    :committer => sig, 4
    :message => "Add newfile.txt", 5
    :parents => repo.empty? ? [] : [ repo.head.target ].compact, 6
    :update_ref => 'HEAD', 7
)
commit = repo.lookup(commit_id) 8
1

파일 내용이 담긴 Blob을 만든다.

2

Index에 Head 커밋의 Tree를 채우고 만든 Blob을 newfile.txt 파일로 추가한다.

3

ODB(Object Database)에 새 트리 개체를 만든다. 커밋할 때는 새 트리 개체가 필요하다.

4

Author와 Committer정보는 한 사람(Signature)으로 한다.

5

커밋 메시지를 입력한다.

6

커밋할 때 부모가 필요하다. 여기서는 HEAD를 부모로 사용한다.

7

Rugged (and Libgit2)는 커밋할 때 Ref 갱신 여부를 선택할 수 있다.

8

리턴한 커밋 개체의 SHA-1 해시로 Commit 객체 가져와 사용한다.

Ruby 코드는 간결하고 깔끔하다. Libgit2을 사용하는 것이기 때문에 여전히 빠르다. 루비스트가 아니라면 “다른 바인딩”에 있는 다른 바인딩을 사용할 수 있다.

고급 기능

Libgit2으로 Git을 확장하는 일도 가능하다. Libgit2에서는 커스텀 “Backend”를 만들어 사용할 수 있다. 그래서 Git이 저장하는 방법 말고 다른 방법으로도 저장할 수 있다. 이것을 Pluggability라고 부른다. 설정, Ref 저장소, 개체 데이터 베이스를 커스텀 “Backend”에 저장할 수 있다.

이게 무슨 소리인지 예제를 살펴보자. 아래 코드는 Libgit2 팀이 제공하는 Backend 예제에서 가져왔다. Libgit2 팀이 제공하는 전체 예제는 https://github.com/libgit2/libgit2-backends에 있다. 개체 데이터베이스의 Backend를 어떻게 사용하는지 보자.

git_odb *odb;
int error = git_odb_new(&odb); 1

git_odb_backend *my_backend;
error = git_odb_backend_mine(&my_backend, /*…*/); 2

error = git_odb_add_backend(odb, my_backend, 1); 3

git_repository *repo;
error = git_repository_open(&repo, "some-path");
error = git_repository_set_odb(odb); 4

(에러는 처리하지 않았다. 실제로 사용할 때는 완벽하리라 믿는다.)

1

“Frontend”로 사용할 ODB(Object DataBase)를 하나 초기화한다. 실제로 저장하는 “Backend”의 컨테이터로 사용한다.

2

ODB Backend를 초기화한다.

3

Frontend에 Backend를 추가한다.

4

저장소를 열고 우리가 만든 ODB를 사용하도록 설정한다. 그러면 개체를 우리가 만든 ODB에서 찾는다.

그런데 git_odb_backend_mine는 뭘까? 이 함수는 우리의 ODB 생성자다. 여기서 원하는 대로 Backend를 만들어 주고 git_odb_backend 구조체만 잘 채우면 된다. 아래처럼 만든다.

typedef struct {
    git_odb_backend parent;

    // Some other stuff
    void *custom_context;
} my_backend_struct;

int git_odb_backend_mine(git_odb_backend **backend_out, /*…*/)
{
    my_backend_struct *backend;

    backend = calloc(1, sizeof (my_backend_struct));

    backend->custom_context = ;

    backend->parent.read = &my_backend__read;
    backend->parent.read_prefix = &my_backend__read_prefix;
    backend->parent.read_header = &my_backend__read_header;
    // …

    *backend_out = (git_odb_backend *) backend;

    return GIT_SUCCESS;
}

my_backend_struct의 첫 번째 맴버는 반드시 git_odb_backend가 돼야 한다. Libgit2가 동작하는 메모리 구조에 맞아야 한다. 나머지 멤버는 상관없다. 구조체 크기는 커도 되고 작아도 된다.

이 초기화 함수에서 구조체를 메모리를 할당하고 커스텀 멤버에 필요한 정보를 설정한다. 그리고 Libgit2에서 필요한 parent 구조체를 채운다. include/git2/sys/odb_backend.h 소스를 보면 git_odb_backend 구조체의 멤버가 어떤 것이 있는지 알 수 있다. 목적에 따라 어떻게 사용해야 하는지 확인해야 한다.

다른 바인딩

Libgit2 바인딩은 많은 언어로 구현돼 있다. 이 글을 쓰는 시점에서 거의 완벽하게 구현됐다고 생각되는 것은 여기서 소개한다. 그 외에도 C++, Go, Node.js, Erlang, JVM 등 많은 언어로 구현돼 있다. https://github.com/libgit2에 가서 살펴보면 어떤 바인딩이 있는지 찾아볼 수 있다. 여기서는 HEAD가 가리키는 커밋의 메시지를 가져오는 코드를 보여준다.

LibGit2Sharp

이 바인딩은 C#으로 작성했고 Libgit2를 감쌌음에도 네이티브 느낌이 나도록 꼼꼼하게 설계했다. 커밋 메시지를 가져오는 예제를 보자.

new Repository(@"C:\path\to\repo").Head.Tip.Message;

윈도 데스크톱 애플리케이션에서 쉽게 사용할 수 있도록 NuGet 패키지도 존재한다.

objective-git

Apple 플랫폼용 애플리케이션을 만들고 있다면 언어가 Objective-C일 것이다. 이 환경에서는 Objective-Git(https://github.com/libgit2/objective-git)을 사용할 수 있다. Objective-C 예제를 보자.

GTRepository *repo =
    [[GTRepository alloc] initWithURL:[NSURL fileURLWithPath: @"/path/to/repo"] error:NULL];
NSString *msg = [[[repo headReferenceWithError:NULL] resolvedTarget] message];

Objective-git는 Swift에서도 사용할 수 있기 때문에 Objective-C가 아니라고 걱정하지 않아도 된다.

pygit2

Python용 바인딩은 Pygit2라고 부른다. http://www.pygit2.org/에서 찾을 수 있다. 예제를 보자.

pygit2.Repository("/path/to/repo") # open repository
    .head                          # get the current branch
    .peel(pygit2.Commit)           # walk down to the commit
    .message                       # read the message

읽을거리

Libgit2를 자세히 설명하는 것은 이 책의 목적에서 벗어난다. Libgit2 자체에 대해서 공부하고 싶다면 Libgit2 가이드(https://libgit2.github.com/docs)와 API 문서(https://libgit2.github.com/libgit2)를 참고한다. Libgit2 바인딩에 대해서 알고 싶다면 해당 프로젝트의 README 파일과 테스트를 참고해야 한다. 읽어보면 어디서부터 시작해야 하는지 알려준다.

JGit

Java에는 JGit이라는 훌륭한 Git 라이브러리가 있다. JGit에는 Git 기능이 한가득 구현돼 있다. 순수하게 Java로 작성됐고 Java 커뮤니티에서 널리 사용한다. The JGit 프로젝트는 Eclipse 재단에 둥지를 틀었고 홈페이지는 http://www.eclipse.org/jgit에 있다.

설치하기

JGit을 프로젝트에 추가해서 코딩을 시작하는 방법은 여러 가지다. 그중 Maven을 사용하는 방법이 가장 쉽다. pom.xml 파일에 <dependencies> 태그를 아래와 같이 추가한다.

<dependency>
    <groupId>org.eclipse.jgit</groupId>
    <artifactId>org.eclipse.jgit</artifactId>
    <version>3.5.0.201409260305-r</version>
</dependency>

version은 시간에 따라 올라갈 것이기 때문에 http://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit에서 최신 버전을 확인해야 한다. 추가하면 Maven이 우리가 명시한 버전의 JGit을 자동으로 추가해준다.

반면 수동으로 바이너리를 관리하고 싶을 수도 있다. http://www.eclipse.org/jgit/download 에서 빌드된 바이너리를 내려받는다. 이 바이너리를 이용해서 아래와 같이 컴파일할 수 있다:

javac -cp .:org.eclipse.jgit-3.5.0.201409260305-r.jar App.java
java -cp .:org.eclipse.jgit-3.5.0.201409260305-r.jar App

Plumbing

JGit의 API는 크게 Plumbing과 Porcelain으로 나눌 수 있다. 이 둘은 Git 용어이고 JGit도 이에 따라 나눈다. 일반 사용자가 사용하는 Git 명령어를 Porcelain 명령어라고 부르는데 이와 관련된 API도 Procelain API라고 부른다. 반대로 Plumbing API는 저장소 개체를 저수준에서 직접 사용하는 API다.

JGit을 사용하는 것은 Repository 클래스의 인스턴스를 만드는 것으로 시작한다. 파일 시스템에 있는 저장소에 접근할 때는 FileRepostiorybuilder를 사용한다.

// Create a new repository
Repository newlyCreatedRepo = FileRepositoryBuilder.create(
    new File("/tmp/new_repo/.git"));
newlyCreatedRepo.create();

// Open an existing repository
Repository existingRepo = new FileRepositoryBuilder()
    .setGitDir(new File("my_repo/.git"))
    .build();

Git 저장소를 나타내는 정보를 하나씩 이 빌더 넘긴다. 넘기는 정보에 따라 조금 다른 API를 사용한다. 환경 변수를 읽고(.readEnvironment()) 워킹 디렉토리를 주고 Git 디렉토리를 찾을 수도 있고(.setWorkTree(…).findGitDir()) 예제로 보여준 것처럼 아예 .git 디렉토리를 바로 넘겨 줄 수도 있다.

Repository 인스턴스를 기점으로 온갖 일을 다 할 수 있다. 예제를 하나 보자.

// Get a reference
Ref master = repo.getRef("master");

// Get the object the reference points to
ObjectId masterTip = master.getObjectId();

// Rev-parse
ObjectId obj = repo.resolve("HEAD^{tree}");

// Load raw object contents
ObjectLoader loader = repo.open(masterTip);
loader.copyTo(System.out);

// Create a branch
RefUpdate createBranch1 = repo.updateRef("refs/heads/branch1");
createBranch1.setNewObjectId(masterTip);
createBranch1.update();

// Delete a branch
RefUpdate deleteBranch1 = repo.updateRef("refs/heads/branch1");
deleteBranch1.setForceUpdate(true);
deleteBranch1.delete();

// Config
Config cfg = repo.getConfig();
String name = cfg.getString("user", null, "name");

이 예제가 어떤 뜻인지 하나씩 살펴보자.

첫 라인에서 master Ref를 얻었다. Jgit은 refs/heads/master에 있는 진짜 master Ref를 가져와서 인스턴스를 리턴한다. 이 객체로 Ref에 대한 정보를 얻을 수 있다. 이름(.getName()), Ref가 가리키는 개체(.getObjectId()), Symbolic Ref가 가리키는 Ref(.getTarget())를 이 객체로 얻을 수 있다. Ref 인스턴스는 태그 Ref와 개체를 나타내고 태그가 “Peeled”인지도 확인할 수 있다. “Peeled”은 껍질을 다 벗긴 상태 그러니까 커밋 개체를 가리키는 상태를 말한다.

두 번째 라인은 master가 가리키는 ObjectId 인스턴스를 리턴한다. ObjectId는 객체의 SHA-1 해시 정보다. 실제로 객체가 Git 객체 데이터베이스에 존재하는지는 상관없다. 셋째 라인도 ObjectId 인스턴스를 리턴하는데 JGit에서 rev-parse 문법을 어떻게 다뤄야 하는지 보여준다. 이 문법은 “브랜치로 가리키기”에서 설명했다. Git이 이해하는 표현은 전부 사용 가능하다. 표현식이 맞으면 해당 객체를 리턴하고 아니면 null을 리턴한다.

그다음 두 라인은 객체의 내용을 읽어서 보여준다. ObjectLoader.copyTo() 함수로 객체의 내용을 표준출력으로 출력(Stream)했다. ObjectLoader에는 객체의 타입과 크기를 알려주거나 객체의 내용을 바이트 배열에 담아서 리턴하는 메소드도 있다. 파일이 큰지도 확인할 수 있다. .isLarge()라는 메소드가 true를 리턴하면 큰 파일이다. 큰 파일이면 .openStream()호출해서 ObjectStream 인스턴스를 얻는다. 이 인스턴스는 일종의 InputStream으로 한 번에 전부 메모리로 올리지 않고 데이터를 처리할 수 있게 해준다.

그다음 몇 라인은 새 브랜치를 만드는 것을 보여준다. RefUpdate 인스턴스를 만들고, 파라미터를 설정하고 나서 .update()를 호출하면 브랜치가 생성된다. 그다음 몇 라인은 만든 브랜치를 삭제하는 코드다. .setForceUpdate(true)는 꼭 필요하다. 이것을 빼먹으면 .delete()REJECTED를 리턴하고 아무 일도 일어나지 않는다.

마지막 예제는 user.name이라는 설정 값을 가져오는 것이다. 이 코드는 마치 해당 저장소의 local 설정만 읽어서 Config 객체를 리턴하는 것 같지만, global 설정과 system 설정까지 잘 찾아서 적용해준다.

여기서는 Plumbing API의 맛보기 정도만 보여줬다. 이용 가능한 메소드와 클래스가 많이 있다. 그리고 JGit의 에러를 처리하는 방법도 생략했다. JGIT API에서는 JGit에서 정의한 NoRemoteRepositoryException, CorruptObjectException, NoMergeBaseException 같은 예외뿐만 아니라 IOExceptioin 같은 Java 표준 예외도 던진다.

Porcelain

Plumbing API로도 모든 일을 다 할 수 있지만, 일반적인 상황에 사용하기에는 좀 귀찮다. Index에 파일을 추가하거나 새로 커밋하는 것 같은 일은 Porcelain API가 낫다. Porcelain API는 고수준에서 사용하기 편하게 하였고 Git 클래스의 인스턴스를 만드는 것으로 시작한다.

Repository repo;
// construct repo...
Git git = new Git(repo);

Git 클래스는 빌더 스타일의 메소드의 집합이라서 복잡해 보이는 일을 쉽게 할 수 있다. git ls-remote 명령어처럼 동작하는 예제를 살펴보자.

CredentialsProvider cp = new UsernamePasswordCredentialsProvider("username", "p4ssw0rd");
Collection<Ref> remoteRefs = git.lsRemote()
    .setCredentialsProvider(cp)
    .setRemote("origin")
    .setTags(true)
    .setHeads(false)
    .call();
for (Ref ref : remoteRefs) {
    System.out.println(ref.getName() + " -> " + ref.getObjectId().name());
}

Git 클래스는 이런 식으로 사용한다. 메소드가 해당 Command 인스턴스를 리턴하면 체이닝으로 메소드를 호출해서 파라미터를 설정하고 .call()을 호출하는 시점에 실제로 실행된다. 이 예제는 origin 리모트의 tag를 요청하는 예제다. head는 빼고 요청한다. 사용자 인증은 CredentialsProvider 객체를 사용한다는 점을 기억하자.

Git 클래스로 실행하는 명령은 매우 많다. 우리에게 익숙한 add, blame, commit, clean, push, rebase, revert, reset 명령 말고도 많다.

읽을거리

여기서는 JGit을 아주 조금만 보여줬다. 자세히 알고 싶다면 아래 링크에서 도움받을 수 있다.