Repositories / ocaml-git.git

ocaml-git.git

Clone (read-only): git clone http://git.guha-anderson.com/git/ocaml-git.git

Branch

Add Kotlin Native libgit2 wrapper

Introduce kotlin-git, a Kotlin/Native library backed by libgit2 cinterop. The public API exposes Kotlin-oriented repository, commit, tree, branch, status, index, and write operations while keeping generated libgit2 bindings internal.

Add Makefile targets for building the library and running deterministic fixture tests against a bundled demo repository.
Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-18 20:31:15 -0400
Commit
e25f59e8c09a6e9e35635a621ce5fc6a53df9bde
.gitignore
new file mode 100644
index 0000000..849d932
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.build/
+build/
+*.kexe
+*.klib
+.kotlin/
+
Makefile
new file mode 100644
index 0000000..cee2c4c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,52 @@
+KOTLIN_HOME ?= /home/arjun/.local/opt/kotlin
+KONANC := $(KOTLIN_HOME)/bin/konanc
+CINTEROP := $(KOTLIN_HOME)/bin/cinterop
+PKG_CONFIG ?= pkg-config
+TARGET ?= linux_x64
+
+BUILD_DIR := .build
+INTEROP_KLIB := $(BUILD_DIR)/klib/libgit2.klib
+LIB_KLIB := $(BUILD_DIR)/klib/kotlin-git.klib
+TEST_BIN := $(BUILD_DIR)/bin/kotlin-git-tests.kexe
+LIBGIT2_INCLUDE_DIR := $(shell $(PKG_CONFIG) --variable=includedir libgit2 2>/dev/null)
+LIBGIT2_LIB_DIR := $(shell $(PKG_CONFIG) --variable=libdir libgit2 2>/dev/null)
+LIBGIT2_CFLAGS := $(shell $(PKG_CONFIG) --cflags libgit2 2>/dev/null)
+LIBGIT2_LIBS := $(shell $(PKG_CONFIG) --libs libgit2 2>/dev/null)
+
+MAIN_SOURCES := $(shell find src/main/kotlin -name '*.kt' | sort)
+TEST_SOURCES := $(shell find src/test/kotlin -name '*.kt' | sort)
+
+.PHONY: all check-system-libgit2 interop test clean fixture
+
+all: $(LIB_KLIB)
+
+check-system-libgit2:
+	$(PKG_CONFIG) --exists libgit2
+
+$(INTEROP_KLIB): src/nativeInterop/cinterop/libgit2.def | check-system-libgit2
+	mkdir -p $(dir $@)
+	$(CINTEROP) -target $(TARGET) -def $< -o $(BUILD_DIR)/klib/libgit2 \
+		-libraryPath $(LIBGIT2_LIB_DIR) \
+		-compiler-option -I$(LIBGIT2_INCLUDE_DIR) \
+		$(foreach flag,$(LIBGIT2_CFLAGS),-compiler-option $(flag))
+
+$(LIB_KLIB): $(MAIN_SOURCES) $(INTEROP_KLIB)
+	mkdir -p $(dir $@)
+	$(KONANC) -target $(TARGET) -produce library -o $(BUILD_DIR)/klib/kotlin-git \
+		-library $(INTEROP_KLIB) $(MAIN_SOURCES)
+
+$(TEST_BIN): $(TEST_SOURCES) $(LIB_KLIB) fixtures/demo-repo.tar.gz
+	mkdir -p $(dir $@)
+	$(KONANC) -target $(TARGET) -o $(BUILD_DIR)/bin/kotlin-git-tests \
+		-library $(INTEROP_KLIB) -library $(LIB_KLIB) $(TEST_SOURCES) \
+		-linker-option -L$(LIBGIT2_LIB_DIR) \
+		$(foreach flag,$(LIBGIT2_LIBS),-linker-option $(flag))
+
+test: $(TEST_BIN)
+	$(TEST_BIN)
+
+fixture:
+	scripts/create-demo-fixture.sh
+
+clean:
+	rm -rf $(BUILD_DIR)
README.md
new file mode 100644
index 0000000..25b16af
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# kotlin-git
+
+A small Kotlin/Native Git library backed by libgit2 through cinterop.
+
+The public API is intentionally Kotlin-oriented and object based. The generated
+libgit2 bindings live in an internal package and are not part of the public API
+surface.
+
+## Requirements
+
+- Kotlin/Native installed at `/home/arjun/.local/opt/kotlin`
+- system libgit2 headers and library visible through `pkg-config libgit2`
+- `make`, `tar`, `git`
+
+## Build
+
+```sh
+make
+```
+
+## Test
+
+```sh
+make test
+```
+
+The test executable unpacks `fixtures/demo-repo.tar.gz` into `.build/test-work`
+and exercises repository discovery, HEAD, commits, tree lookup, branches,
+statuses, index operations, and write flows against that deterministic fixture.
fixtures/demo-repo.tar.gz
new file mode 100644
index 0000000..6b001d3
Binary files /dev/null and b/fixtures/demo-repo.tar.gz differ
scripts/create-demo-fixture.sh
new file mode 100755
index 0000000..58e88b6
--- /dev/null
+++ b/scripts/create-demo-fixture.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORK="$ROOT/.build/fixture-work"
+REPO="$WORK/demo-repo"
+OUT="$ROOT/fixtures/demo-repo.tar.gz"
+
+rm -rf "$WORK"
+mkdir -p "$REPO" "$ROOT/fixtures"
+
+git -C "$REPO" init -b main >/dev/null
+git -C "$REPO" config user.name "Fixture Author"
+git -C "$REPO" config user.email "fixture@example.com"
+
+cat > "$REPO/README.md" <<'TXT'
+# Demo repository
+
+This repository is a deterministic fixture for kotlin-git tests.
+TXT
+mkdir -p "$REPO/src"
+cat > "$REPO/src/hello.txt" <<'TXT'
+hello
+TXT
+git -C "$REPO" add README.md src/hello.txt
+GIT_AUTHOR_DATE="2024-01-02T03:04:05Z" \
+GIT_COMMITTER_DATE="2024-01-02T03:04:05Z" \
+  git -C "$REPO" commit -m "Initial fixture commit" >/dev/null
+
+git -C "$REPO" checkout -b feature/demo >/dev/null
+cat > "$REPO/src/feature.txt" <<'TXT'
+feature
+TXT
+git -C "$REPO" add src/feature.txt
+GIT_AUTHOR_DATE="2024-01-03T03:04:05Z" \
+GIT_COMMITTER_DATE="2024-01-03T03:04:05Z" \
+  git -C "$REPO" commit -m "Add feature file" >/dev/null
+
+git -C "$REPO" checkout main >/dev/null
+cat >> "$REPO/README.md" <<'TXT'
+
+Second line.
+TXT
+git -C "$REPO" add README.md
+GIT_AUTHOR_DATE="2024-01-04T03:04:05Z" \
+GIT_COMMITTER_DATE="2024-01-04T03:04:05Z" \
+  git -C "$REPO" commit -m "Update readme" >/dev/null
+
+git -C "$REPO" tag v1.0
+git -C "$REPO" checkout main >/dev/null
+
+tar --sort=name --mtime="2024-01-05 00:00Z" --owner=0 --group=0 --numeric-owner \
+  -C "$WORK" -czf "$OUT" demo-repo
+
+echo "$OUT"
+
src/main/kotlin/kotlinx/git/Git.kt
new file mode 100644
index 0000000..9dca139
--- /dev/null
+++ b/src/main/kotlin/kotlinx/git/Git.kt
@@ -0,0 +1,18 @@
+package kotlinx.git
+
+import kotlinx.git.internal.Libgit2
+
+object Git {
+    fun open(path: String): Repository = Libgit2.ensureInitialized {
+        Repository.open(path)
+    }
+
+    fun init(path: String, bare: Boolean = false): Repository = Libgit2.ensureInitialized {
+        Repository.init(path, bare)
+    }
+
+    fun discover(startPath: String): String = Libgit2.ensureInitialized {
+        Repository.discover(startPath)
+    }
+}
+
src/main/kotlin/kotlinx/git/GitException.kt
new file mode 100644
index 0000000..56ab638
--- /dev/null
+++ b/src/main/kotlin/kotlinx/git/GitException.kt
@@ -0,0 +1,4 @@
+package kotlinx.git
+
+class GitException(message: String) : RuntimeException(message)
+
src/main/kotlin/kotlinx/git/Models.kt
new file mode 100644
index 0000000..74b48e0
--- /dev/null
+++ b/src/main/kotlin/kotlinx/git/Models.kt
@@ -0,0 +1,73 @@
+package kotlinx.git
+
+data class Oid(val value: String) {
+    init {
+        require(value.length == 40) { "Git object ids must be 40 hex characters" }
+    }
+
+    override fun toString(): String = value
+}
+
+data class Signature(
+    val name: String,
+    val email: String,
+    val epochSeconds: Long,
+    val timezoneOffsetMinutes: Int,
+)
+
+data class Commit(
+    val id: Oid,
+    val summary: String,
+    val message: String,
+    val author: Signature,
+    val committer: Signature,
+    val parentCount: UInt,
+)
+
+enum class BranchType {
+    Local,
+    Remote,
+}
+
+data class Branch(
+    val name: String,
+    val type: BranchType,
+    val isHead: Boolean,
+)
+
+data class TreeEntry(
+    val name: String,
+    val id: Oid,
+    val kind: TreeEntryKind,
+)
+
+enum class TreeEntryKind {
+    Blob,
+    Tree,
+    Commit,
+    Tag,
+    Other,
+}
+
+enum class StatusFlag {
+    Current,
+    IndexNew,
+    IndexModified,
+    IndexDeleted,
+    IndexRenamed,
+    IndexTypeChange,
+    WorktreeNew,
+    WorktreeModified,
+    WorktreeDeleted,
+    WorktreeTypeChange,
+    WorktreeRenamed,
+    WorktreeUnreadable,
+    Ignored,
+    Conflicted,
+}
+
+data class StatusEntry(
+    val path: String,
+    val flags: Set<StatusFlag>,
+)
+
src/main/kotlin/kotlinx/git/Repository.kt
new file mode 100644
index 0000000..124672a
--- /dev/null
+++ b/src/main/kotlin/kotlinx/git/Repository.kt
@@ -0,0 +1,384 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package kotlinx.git
+
+import cnames.structs.git_branch_iterator
+import cnames.structs.git_commit
+import cnames.structs.git_index
+import cnames.structs.git_reference
+import cnames.structs.git_repository
+import cnames.structs.git_status_list
+import cnames.structs.git_tree
+import cnames.structs.git_tree_entry
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.CPointer
+import kotlinx.cinterop.CPointerVar
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.IntVar
+import kotlinx.cinterop.UIntVar
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.cstr
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.get
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.pointed
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.set
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.value
+import kotlinx.git.internal.Libgit2
+import kotlinx.git.internal.git2.GIT_BRANCH_ALL
+import kotlinx.git.internal.git2.GIT_BRANCH_LOCAL
+import kotlinx.git.internal.git2.GIT_ITEROVER
+import kotlinx.git.internal.git2.GIT_OBJECT_BLOB
+import kotlinx.git.internal.git2.GIT_OBJECT_COMMIT
+import kotlinx.git.internal.git2.GIT_OBJECT_TAG
+import kotlinx.git.internal.git2.GIT_OBJECT_TREE
+import kotlinx.git.internal.git2.GIT_STATUS_CONFLICTED
+import kotlinx.git.internal.git2.GIT_STATUS_CURRENT
+import kotlinx.git.internal.git2.GIT_STATUS_IGNORED
+import kotlinx.git.internal.git2.GIT_STATUS_INDEX_DELETED
+import kotlinx.git.internal.git2.GIT_STATUS_INDEX_MODIFIED
+import kotlinx.git.internal.git2.GIT_STATUS_INDEX_NEW
+import kotlinx.git.internal.git2.GIT_STATUS_INDEX_RENAMED
+import kotlinx.git.internal.git2.GIT_STATUS_INDEX_TYPECHANGE
+import kotlinx.git.internal.git2.GIT_STATUS_OPT_DEFAULTS
+import kotlinx.git.internal.git2.GIT_STATUS_OPTIONS_VERSION
+import kotlinx.git.internal.git2.GIT_STATUS_WT_DELETED
+import kotlinx.git.internal.git2.GIT_STATUS_WT_MODIFIED
+import kotlinx.git.internal.git2.GIT_STATUS_WT_NEW
+import kotlinx.git.internal.git2.GIT_STATUS_WT_RENAMED
+import kotlinx.git.internal.git2.GIT_STATUS_WT_TYPECHANGE
+import kotlinx.git.internal.git2.GIT_STATUS_WT_UNREADABLE
+import kotlinx.git.internal.git2.git_branch_is_head
+import kotlinx.git.internal.git2.git_branch_iterator_free
+import kotlinx.git.internal.git2.git_branch_iterator_new
+import kotlinx.git.internal.git2.git_branch_name
+import kotlinx.git.internal.git2.git_branch_next
+import kotlinx.git.internal.git2.git_branch_t
+import kotlinx.git.internal.git2.git_commit_author
+import kotlinx.git.internal.git2.git_commit_committer
+import kotlinx.git.internal.git2.git_commit_free
+import kotlinx.git.internal.git2.git_commit_id
+import kotlinx.git.internal.git2.git_commit_lookup
+import kotlinx.git.internal.git2.git_commit_message
+import kotlinx.git.internal.git2.git_commit_parentcount
+import kotlinx.git.internal.git2.git_commit_summary
+import kotlinx.git.internal.git2.git_commit_tree
+import kotlinx.git.internal.git2.git_index_add_bypath
+import kotlinx.git.internal.git2.git_index_entrycount
+import kotlinx.git.internal.git2.git_index_free
+import kotlinx.git.internal.git2.git_index_get_bypath
+import kotlinx.git.internal.git2.git_index_remove_bypath
+import kotlinx.git.internal.git2.git_index_write
+import kotlinx.git.internal.git2.git_oid
+import kotlinx.git.internal.git2.git_oid_fmt
+import kotlinx.git.internal.git2.git_oid_fromstr
+import kotlinx.git.internal.git2.git_reference_free
+import kotlinx.git.internal.git2.git_reference_name
+import kotlinx.git.internal.git2.git_reference_target
+import kotlinx.git.internal.git2.git_repository_free
+import kotlinx.git.internal.git2.git_repository_head
+import kotlinx.git.internal.git2.git_repository_index
+import kotlinx.git.internal.git2.git_repository_init
+import kotlinx.git.internal.git2.git_repository_is_bare
+import kotlinx.git.internal.git2.git_repository_is_empty
+import kotlinx.git.internal.git2.git_repository_open
+import kotlinx.git.internal.git2.git_repository_open_ext
+import kotlinx.git.internal.git2.git_repository_path
+import kotlinx.git.internal.git2.git_repository_workdir
+import kotlinx.git.internal.git2.git_signature
+import kotlinx.git.internal.git2.git_status_byindex
+import kotlinx.git.internal.git2.git_status_list_entrycount
+import kotlinx.git.internal.git2.git_status_list_free
+import kotlinx.git.internal.git2.git_status_list_new
+import kotlinx.git.internal.git2.git_status_options
+import kotlinx.git.internal.git2.git_status_options_init
+import kotlinx.git.internal.git2.git_status_t
+import kotlinx.git.internal.git2.git_tree_entry_bypath
+import kotlinx.git.internal.git2.git_tree_entry_free
+import kotlinx.git.internal.git2.git_tree_entry_id
+import kotlinx.git.internal.git2.git_tree_entry_name
+import kotlinx.git.internal.git2.git_tree_entry_type
+import kotlinx.git.internal.git2.git_tree_free
+
+class Repository internal constructor(
+    private var handle: CPointer<git_repository>?,
+) : AutoCloseable {
+    val gitDir: String
+        get() = requireOpen().let { git_repository_path(it)?.toKString() ?: "" }
+
+    val workdir: String?
+        get() = requireOpen().let { git_repository_workdir(it)?.toKString() }
+
+    val isBare: Boolean
+        get() = git_repository_is_bare(requireOpen()) == 1
+
+    val isEmpty: Boolean
+        get() = git_repository_is_empty(requireOpen()) == 1
+
+    fun headName(): String = memScoped {
+        val ref = alloc<CPointerVar<git_reference>>()
+        Libgit2.check(git_repository_head(ref.ptr, requireOpen()), "git_repository_head")
+        try {
+            git_reference_name(ref.value)?.toKString() ?: throw GitException("HEAD has no reference name")
+        } finally {
+            git_reference_free(ref.value)
+        }
+    }
+
+    fun headCommit(): Commit = memScoped {
+        val ref = alloc<CPointerVar<git_reference>>()
+        Libgit2.check(git_repository_head(ref.ptr, requireOpen()), "git_repository_head")
+        try {
+            val target = git_reference_target(ref.value) ?: throw GitException("HEAD does not point to an object id")
+            lookupCommit(target)
+        } finally {
+            git_reference_free(ref.value)
+        }
+    }
+
+    fun branches(): List<Branch> = memScoped {
+        val iter = alloc<CPointerVar<git_branch_iterator>>()
+        Libgit2.check(git_branch_iterator_new(iter.ptr, requireOpen(), GIT_BRANCH_ALL), "git_branch_iterator_new")
+        val result = mutableListOf<Branch>()
+        try {
+            while (true) {
+                val ref = alloc<CPointerVar<git_reference>>()
+                val type = alloc<UIntVar>()
+                val code = git_branch_next(ref.ptr, type.ptr, iter.value)
+                if (code == GIT_ITEROVER) break
+                Libgit2.check(code, "git_branch_next")
+
+                try {
+                    val name = alloc<CPointerVar<ByteVar>>()
+                    Libgit2.check(git_branch_name(name.ptr, ref.value), "git_branch_name")
+                    result += Branch(
+                        name = name.value?.toKString() ?: "",
+                        type = if (type.value == GIT_BRANCH_LOCAL) BranchType.Local else BranchType.Remote,
+                        isHead = git_branch_is_head(ref.value) == 1,
+                    )
+                } finally {
+                    git_reference_free(ref.value)
+                }
+            }
+        } finally {
+            git_branch_iterator_free(iter.value)
+        }
+        result
+    }
+
+    fun status(): List<StatusEntry> = memScoped {
+        val options = alloc<git_status_options>()
+        Libgit2.check(git_status_options_init(options.ptr, GIT_STATUS_OPTIONS_VERSION.toUInt()), "git_status_options_init")
+        options.flags = GIT_STATUS_OPT_DEFAULTS.toUInt()
+
+        val list = alloc<CPointerVar<git_status_list>>()
+        Libgit2.check(git_status_list_new(list.ptr, requireOpen(), options.ptr), "git_status_list_new")
+        try {
+            val count = git_status_list_entrycount(list.value).toInt()
+            (0 until count).map { index ->
+                val entry = git_status_byindex(list.value, index.convert())
+                    ?: throw GitException("Status entry $index was unexpectedly null")
+                val pointed = entry.pointed
+                StatusEntry(statusPath(pointed), statusFlags(pointed.status))
+            }
+        } finally {
+            git_status_list_free(list.value)
+        }
+    }
+
+    fun index(): Index = memScoped {
+        val index = alloc<CPointerVar<git_index>>()
+        Libgit2.check(git_repository_index(index.ptr, requireOpen()), "git_repository_index")
+        Index(index.value)
+    }
+
+    fun treeEntry(path: String, commit: Commit = headCommit()): TreeEntry = memScoped {
+        val oid = alloc<git_oid>()
+        oid.fromOid(commit.id)
+        val gitCommit = alloc<CPointerVar<git_commit>>()
+        Libgit2.check(git_commit_lookup(gitCommit.ptr, requireOpen(), oid.ptr), "git_commit_lookup")
+        try {
+            val tree = alloc<CPointerVar<git_tree>>()
+            Libgit2.check(git_commit_tree(tree.ptr, gitCommit.value), "git_commit_tree")
+            try {
+                val entry = alloc<CPointerVar<git_tree_entry>>()
+                Libgit2.check(git_tree_entry_bypath(entry.ptr, tree.value, path), "git_tree_entry_bypath")
+                try {
+                    val id = git_tree_entry_id(entry.value) ?: throw GitException("Tree entry has no id")
+                    TreeEntry(
+                        name = git_tree_entry_name(entry.value)?.toKString() ?: path.substringAfterLast('/'),
+                        id = id.toOid(),
+                        kind = git_tree_entry_type(entry.value).toTreeEntryKind(),
+                    )
+                } finally {
+                    git_tree_entry_free(entry.value)
+                }
+            } finally {
+                git_tree_free(tree.value)
+            }
+        } finally {
+            git_commit_free(gitCommit.value)
+        }
+    }
+
+    override fun close() {
+        handle?.let { git_repository_free(it) }
+        handle = null
+    }
+
+    private fun requireOpen(): CPointer<git_repository> =
+        handle ?: throw GitException("Repository is closed")
+
+    private fun lookupCommit(oid: CPointer<git_oid>): Commit = memScoped {
+        val commit = alloc<CPointerVar<git_commit>>()
+        Libgit2.check(git_commit_lookup(commit.ptr, requireOpen(), oid), "git_commit_lookup")
+        try {
+            commit.value?.toCommit() ?: throw GitException("Commit lookup returned null")
+        } finally {
+            git_commit_free(commit.value)
+        }
+    }
+
+    internal companion object {
+        fun open(path: String): Repository = memScoped {
+            val repo = alloc<CPointerVar<git_repository>>()
+            Libgit2.check(git_repository_open(repo.ptr, path), "git_repository_open")
+            Repository(repo.value)
+        }
+
+        fun init(path: String, bare: Boolean): Repository = memScoped {
+            val repo = alloc<CPointerVar<git_repository>>()
+            Libgit2.check(git_repository_init(repo.ptr, path, if (bare) 1u else 0u), "git_repository_init")
+            Repository(repo.value)
+        }
+
+        fun discover(startPath: String): String = memScoped {
+            val repo = alloc<CPointerVar<git_repository>>()
+            Libgit2.check(git_repository_open_ext(repo.ptr, startPath, 0u, null), "git_repository_open_ext")
+            try {
+                git_repository_path(repo.value)?.toKString() ?: throw GitException("Repository discovery returned no path")
+            } finally {
+                git_repository_free(repo.value)
+            }
+        }
+    }
+}
+
+class Index internal constructor(
+    private var handle: CPointer<git_index>?,
+) : AutoCloseable {
+    val size: Int
+        get() = git_index_entrycount(requireOpen()).toInt()
+
+    fun contains(path: String): Boolean = memScoped {
+        git_index_get_bypath(requireOpen(), path, 0) != null
+    }
+
+    fun add(path: String): Index {
+        memScoped {
+            Libgit2.check(git_index_add_bypath(requireOpen(), path), "git_index_add_bypath")
+        }
+        return this
+    }
+
+    fun remove(path: String): Index {
+        memScoped {
+            Libgit2.check(git_index_remove_bypath(requireOpen(), path), "git_index_remove_bypath")
+        }
+        return this
+    }
+
+    fun write(): Index {
+        Libgit2.check(git_index_write(requireOpen()), "git_index_write")
+        return this
+    }
+
+    override fun close() {
+        handle?.let { git_index_free(it) }
+        handle = null
+    }
+
+    private fun requireOpen(): CPointer<git_index> =
+        handle ?: throw GitException("Index is closed")
+}
+
+private fun CPointer<git_commit>.toCommit(): Commit {
+    val author = git_commit_author(this) ?: throw GitException("Commit has no author")
+    val committer = git_commit_committer(this) ?: throw GitException("Commit has no committer")
+    val id = git_commit_id(this) ?: throw GitException("Commit has no object id")
+    return Commit(
+        id = id.toOid(),
+        summary = git_commit_summary(this)?.toKString() ?: "",
+        message = git_commit_message(this)?.toKString() ?: "",
+        author = author.toSignature(),
+        committer = committer.toSignature(),
+        parentCount = git_commit_parentcount(this),
+    )
+}
+
+private fun CPointer<git_signature>.toSignature(): Signature {
+    val signature = pointed
+    return Signature(
+        name = signature.name?.toKString() ?: "",
+        email = signature.email?.toKString() ?: "",
+        epochSeconds = signature.`when`.time,
+        timezoneOffsetMinutes = signature.`when`.offset,
+    )
+}
+
+private fun CPointer<git_oid>.toOid(): Oid = memScoped {
+    val out = allocArray<ByteVar>(41)
+    git_oid_fmt(out, this@toOid)
+    out[40] = 0.toByte()
+    Oid(out.toKString())
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun git_oid.fromOid(oid: Oid) {
+    memScoped {
+        Libgit2.check(git_oid_fromstr(this@fromOid.ptr, oid.value), "git_oid_fromstr")
+    }
+}
+
+private fun git_status_t.toFlagsUInt(): UInt = toUInt()
+
+private fun statusFlags(status: git_status_t): Set<StatusFlag> {
+    val bits = status.toFlagsUInt()
+    if (bits == GIT_STATUS_CURRENT) return setOf(StatusFlag.Current)
+    val flags = mutableSetOf<StatusFlag>()
+    fun add(flag: UInt, statusFlag: StatusFlag) {
+        if ((bits and flag) != 0u) flags += statusFlag
+    }
+    add(GIT_STATUS_INDEX_NEW, StatusFlag.IndexNew)
+    add(GIT_STATUS_INDEX_MODIFIED, StatusFlag.IndexModified)
+    add(GIT_STATUS_INDEX_DELETED, StatusFlag.IndexDeleted)
+    add(GIT_STATUS_INDEX_RENAMED, StatusFlag.IndexRenamed)
+    add(GIT_STATUS_INDEX_TYPECHANGE, StatusFlag.IndexTypeChange)
+    add(GIT_STATUS_WT_NEW, StatusFlag.WorktreeNew)
+    add(GIT_STATUS_WT_MODIFIED, StatusFlag.WorktreeModified)
+    add(GIT_STATUS_WT_DELETED, StatusFlag.WorktreeDeleted)
+    add(GIT_STATUS_WT_TYPECHANGE, StatusFlag.WorktreeTypeChange)
+    add(GIT_STATUS_WT_RENAMED, StatusFlag.WorktreeRenamed)
+    add(GIT_STATUS_WT_UNREADABLE, StatusFlag.WorktreeUnreadable)
+    add(GIT_STATUS_IGNORED, StatusFlag.Ignored)
+    add(GIT_STATUS_CONFLICTED, StatusFlag.Conflicted)
+    return flags
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun statusPath(entry: kotlinx.git.internal.git2.git_status_entry): String {
+    val delta = entry.index_to_workdir ?: entry.head_to_index
+    val path = delta?.pointed?.new_file?.path ?: delta?.pointed?.old_file?.path
+    return path?.toKString() ?: ""
+}
+
+private fun Int.toTreeEntryKind(): TreeEntryKind = when (this) {
+    GIT_OBJECT_BLOB -> TreeEntryKind.Blob
+    GIT_OBJECT_TREE -> TreeEntryKind.Tree
+    GIT_OBJECT_COMMIT -> TreeEntryKind.Commit
+    GIT_OBJECT_TAG -> TreeEntryKind.Tag
+    else -> TreeEntryKind.Other
+}
src/main/kotlin/kotlinx/git/internal/Libgit2.kt
new file mode 100644
index 0000000..dd0ed38
--- /dev/null
+++ b/src/main/kotlin/kotlinx/git/internal/Libgit2.kt
@@ -0,0 +1,36 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package kotlinx.git.internal
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.pointed
+import kotlinx.cinterop.toKString
+import kotlinx.git.GitException
+import kotlinx.git.internal.git2.git_error_last
+import kotlinx.git.internal.git2.git_libgit2_init
+import kotlinx.git.internal.git2.git_libgit2_shutdown
+
+internal object Libgit2 {
+    private var initialized = false
+
+    fun <T> ensureInitialized(block: () -> T): T {
+        if (!initialized) {
+            check(git_libgit2_init(), "git_libgit2_init")
+            initialized = true
+        }
+        return block()
+    }
+
+    fun shutdown() {
+        if (initialized) {
+            git_libgit2_shutdown()
+            initialized = false
+        }
+    }
+
+    fun check(code: Int, operation: String) {
+        if (code >= 0) return
+        val message = git_error_last()?.pointed?.message?.toKString()
+        throw GitException("$operation failed: ${message ?: "libgit2 error $code"}")
+    }
+}
src/nativeInterop/cinterop/libgit2.def
new file mode 100644
index 0000000..adf39b2
--- /dev/null
+++ b/src/nativeInterop/cinterop/libgit2.def
@@ -0,0 +1,3 @@
+package = kotlinx.git.internal.git2
+headers = git2.h
+linkerOpts.linux = -lgit2
src/test/kotlin/TestMain.kt
new file mode 100644
index 0000000..7135519
--- /dev/null
+++ b/src/test/kotlin/TestMain.kt
@@ -0,0 +1,155 @@
+import kotlinx.git.BranchType
+import kotlinx.git.Git
+import kotlinx.git.StatusFlag
+import kotlinx.git.TreeEntryKind
+import platform.posix.system
+import kotlin.system.exitProcess
+
+private const val WorkRoot = ".build/test-work"
+private const val RepoPath = "$WorkRoot/demo-repo"
+
+fun main(args: Array<String>) {
+    args.size
+    val tests = listOf(
+        ::opensRepository,
+        ::discoversRepositoryFromChild,
+        ::readsHeadCommit,
+        ::listsBranches,
+        ::looksUpTreeEntries,
+        ::reportsCleanStatus,
+        ::reportsWorktreeStatus,
+        ::updatesIndex,
+        ::initializesRepository,
+        ::rejectsMissingRepository,
+    )
+
+    var passed = 0
+    for (test in tests) {
+        resetFixture()
+        try {
+            test()
+            println("PASS ${test.name}")
+            passed++
+        } catch (throwable: Throwable) {
+            println("FAIL ${test.name}: ${throwable.message}")
+            throwable.printStackTrace()
+            exitProcess(1)
+        }
+    }
+    println("PASS $passed tests")
+}
+
+private fun opensRepository() {
+    Git.open(RepoPath).use { repo ->
+        check(repo.workdir?.endsWith("demo-repo/") == true) { "unexpected workdir ${repo.workdir}" }
+        check(repo.gitDir.endsWith("demo-repo/.git/")) { "unexpected gitDir ${repo.gitDir}" }
+        check(!repo.isBare) { "fixture should be non-bare" }
+        check(!repo.isEmpty) { "fixture should not be empty" }
+        check(repo.headName() == "refs/heads/main") { "unexpected HEAD ${repo.headName()}" }
+    }
+}
+
+private fun discoversRepositoryFromChild() {
+    val discovered = Git.discover("$RepoPath/src")
+    check(discovered.endsWith("demo-repo/.git/")) { "unexpected discovery path $discovered" }
+}
+
+private fun readsHeadCommit() {
+    Git.open(RepoPath).use { repo ->
+        val commit = repo.headCommit()
+        check(commit.id.value.length == 40) { "bad oid ${commit.id}" }
+        check(commit.summary == "Update readme") { "unexpected summary ${commit.summary}" }
+        check(commit.message.contains("Update readme")) { "unexpected message ${commit.message}" }
+        check(commit.author.name == "Fixture Author") { "unexpected author ${commit.author}" }
+        check(commit.author.email == "fixture@example.com") { "unexpected author ${commit.author}" }
+        check(commit.parentCount == 1u) { "unexpected parent count ${commit.parentCount}" }
+    }
+}
+
+private fun listsBranches() {
+    Git.open(RepoPath).use { repo ->
+        val branches = repo.branches()
+        val names = branches.map { it.name }.toSet()
+        check("main" in names) { "missing main branch in $branches" }
+        check("feature/demo" in names) { "missing feature branch in $branches" }
+        check(branches.single { it.name == "main" }.isHead) { "main should be HEAD" }
+        check(branches.all { it.type == BranchType.Local }) { "fixture should only have local branches: $branches" }
+    }
+}
+
+private fun looksUpTreeEntries() {
+    Git.open(RepoPath).use { repo ->
+        val readme = repo.treeEntry("README.md")
+        check(readme.name == "README.md") { "unexpected readme entry $readme" }
+        check(readme.kind == TreeEntryKind.Blob) { "README should be a blob: $readme" }
+        check(readme.id.value.length == 40) { "bad blob oid ${readme.id}" }
+
+        val src = repo.treeEntry("src")
+        check(src.kind == TreeEntryKind.Tree) { "src should be a tree: $src" }
+    }
+}
+
+private fun reportsCleanStatus() {
+    Git.open(RepoPath).use { repo ->
+        check(repo.status().isEmpty()) { "fixture should be clean: ${repo.status()}" }
+    }
+}
+
+private fun reportsWorktreeStatus() {
+    sh("printf '\\nlocal edit\\n' >> $RepoPath/README.md")
+    sh("printf 'notes\\n' > $RepoPath/notes.txt")
+
+    Git.open(RepoPath).use { repo ->
+        val byPath = repo.status().associateBy { it.path }
+        check(StatusFlag.WorktreeModified in byPath.getValue("README.md").flags) { "README not modified: $byPath" }
+        check(StatusFlag.WorktreeNew in byPath.getValue("notes.txt").flags) { "notes not untracked: $byPath" }
+    }
+}
+
+private fun updatesIndex() {
+    sh("printf 'notes\\n' > $RepoPath/notes.txt")
+    Git.open(RepoPath).use { repo ->
+        repo.index().use { index ->
+            check(index.contains("README.md")) { "README should already be indexed" }
+            val originalSize = index.size
+            index.add("notes.txt").write()
+            check(index.contains("notes.txt")) { "notes should be indexed" }
+            check(index.size == originalSize + 1) { "index size did not increase" }
+        }
+
+        val notes = repo.status().single { it.path == "notes.txt" }
+        check(StatusFlag.IndexNew in notes.flags) { "notes should be staged: $notes" }
+    }
+}
+
+private fun initializesRepository() {
+    val path = "$WorkRoot/new-repo"
+    sh("rm -rf $path && mkdir -p $path")
+    Git.init(path).use { repo ->
+        check(!repo.isBare) { "new repo should be non-bare" }
+        check(repo.isEmpty) { "new repo should be empty" }
+        check(repo.workdir?.endsWith("new-repo/") == true) { "unexpected new workdir ${repo.workdir}" }
+    }
+}
+
+private fun rejectsMissingRepository() {
+    val failed = runCatching { Git.open("$WorkRoot/missing") }.isFailure
+    check(failed) { "opening a missing repository should fail" }
+}
+
+private fun resetFixture() {
+    sh("rm -rf $WorkRoot && mkdir -p $WorkRoot && tar -xzf fixtures/demo-repo.tar.gz -C $WorkRoot")
+}
+
+private fun sh(command: String) {
+    val code = system(command)
+    check(code == 0) { "command failed with $code: $command" }
+}
+
+private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
+    try {
+        return block(this)
+    } finally {
+        close()
+    }
+}