Repositories / ocaml-git.git

ocaml-git.git

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

Branch

Convert package to OCaml libgit2 library

Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-30 04:49:33 -0400
Commit
1a9c38f92ecb075831c72aed9cce011139ba12d9
Makefile
index f89c6fb..22b02cd 100644
--- a/Makefile
+++ b/Makefile
@@ -3,13 +3,13 @@
 all: build
 
 build:
-	./gradlew build
+	dune build
 
 test:
-	./gradlew test
+	dune test
 
 clean:
-	./gradlew clean
+	dune clean
 	rm -rf .build
 
 fixture:
README.md
index 0dad072..1b57f7f 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,34 @@
-# kotlin-git
+# ocaml-git
 
-A small Kotlin/Native Git library backed by libgit2 through cinterop.
+A small OCaml Git library backed by the C `libgit2` library.
 
-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.
+The public API is typed OCaml data for repositories, commits, branches, trees,
+blobs, status entries, and index operations. The implementation uses OCaml C
+stubs linked with `-lgit2`.
 
 ## Requirements
 
-- JDK 17 or newer for Gradle
-- system libgit2 headers and library visible through `pkg-config libgit2`
-- `tar`, `git`
+- OCaml 5.4 or newer
+- opam
+- dune
+- system `libgit2` headers and shared library
+- system `git` for the fixture setup in tests
+- `tar` for the fixture tests
 
 ## Build
 
 ```sh
-./gradlew build
+opam install . --deps-only --with-test
+dune build
 ```
 
-This project uses the Kotlin Multiplatform Gradle plugin with only a
-Kotlin/Native `linuxX64` target configured. It does not configure or produce a
-JVM target.
-
 ## Test
 
 ```sh
-./gradlew test
+dune 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.
+statuses, index operations, and repository initialization against that
+deterministic fixture.
build.gradle.kts
deleted file mode 100644
index bbbb7a9..0000000
--- a/build.gradle.kts
+++ /dev/null
@@ -1,97 +0,0 @@
-import org.gradle.language.base.plugins.LifecycleBasePlugin
-
-plugins {
-    kotlin("multiplatform") version "2.3.20"
-}
-
-group = "kotlinx.git"
-version = "0.1.0-SNAPSHOT"
-
-fun pkgConfig(vararg args: String): List<String> {
-    val output = providers.exec {
-        commandLine("pkg-config", *args, "libgit2")
-    }.standardOutput.asText.get()
-
-    return output
-        .trim()
-        .split(Regex("\\s+"))
-        .filter { it.isNotBlank() }
-}
-
-fun pkgConfigValue(variable: String): String {
-    return providers.exec {
-        commandLine("pkg-config", "--variable=$variable", "libgit2")
-    }.standardOutput.asText.get().trim()
-}
-
-val libgit2CFlags = pkgConfig("--cflags")
-val libgit2IncludeDir = pkgConfigValue("includedir")
-val libgit2Libs = pkgConfig("--libs")
-val libgit2LibDir = pkgConfigValue("libdir")
-
-kotlin {
-    linuxX64 {
-        compilations.getByName("main") {
-            cinterops {
-                val libgit2 by creating {
-                    defFile(project.file("src/nativeInterop/cinterop/libgit2.def"))
-                    compilerOpts(libgit2CFlags)
-                    if (libgit2IncludeDir.isNotBlank()) {
-                        compilerOpts("-I$libgit2IncludeDir")
-                    }
-                    if (libgit2LibDir.isNotBlank()) {
-                        extraOpts("-libraryPath", libgit2LibDir)
-                    }
-                }
-            }
-        }
-
-        binaries.all {
-            if (libgit2LibDir.isNotBlank()) {
-                linkerOpts("-L$libgit2LibDir")
-            }
-            linkerOpts(libgit2Libs)
-        }
-
-        binaries.executable("tests") {
-            compilation = compilations.getByName("test")
-            baseName = "kotlin-git-tests"
-            entryPoint = "main"
-        }
-
-        tasks.register<Exec>("nativeTest") {
-            group = LifecycleBasePlugin.VERIFICATION_GROUP
-            description = "Runs the Kotlin/Native test executable."
-            dependsOn("linkTestsDebugExecutableLinuxX64")
-            workingDir = rootProject.projectDir
-            executable = layout.buildDirectory
-                .file("bin/linuxX64/testsDebugExecutable/kotlin-git-tests.kexe")
-                .get()
-                .asFile
-                .absolutePath
-        }
-    }
-
-    sourceSets {
-        val linuxX64Main by getting {
-            kotlin.srcDir("src/main/kotlin")
-        }
-        val linuxX64Test by getting {
-            kotlin.srcDir("src/test/kotlin")
-        }
-    }
-}
-
-tasks.register("test") {
-    group = LifecycleBasePlugin.VERIFICATION_GROUP
-    description = "Runs all tests."
-    dependsOn("nativeTest")
-}
-
-tasks.named("linuxX64Test") {
-    enabled = false
-}
-
-tasks.named("check") {
-    dependsOn("nativeTest")
-}
dune-project
new file mode 100644
index 0000000..3aa1234
--- /dev/null
+++ b/dune-project
@@ -0,0 +1,11 @@
+(lang dune 3.22)
+(name ocaml-git)
+(generate_opam_files true)
+
+(package
+ (name ocaml-git)
+ (synopsis "Small OCaml Git library")
+ (depends
+  (ocaml (>= 5.4))
+  dune
+  alcotest))
gradle.properties
deleted file mode 100644
index e939bec..0000000
--- a/gradle.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-kotlin.code.style=official
-kotlin.native.ignoreDisabledTargets=true
gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index 61285a6..0000000
Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index 19a6bde..0000000
--- a/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
gradlew
deleted file mode 100755
index adff685..0000000
--- a/gradlew
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-# SPDX-License-Identifier: Apache-2.0
-#
-
-##############################################################################
-#
-#   Gradle start up script for POSIX generated by Gradle.
-#
-#   Important for running:
-#
-#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-#       noncompliant, but you have some other compliant shell such as ksh or
-#       bash, then to run this script, type that shell name before the whole
-#       command line, like:
-#
-#           ksh Gradle
-#
-#       Busybox and similar reduced shells will NOT work, because this script
-#       requires all of these POSIX shell features:
-#         * functions;
-#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-#         * compound commands having a testable exit status, especially «case»;
-#         * various built-in commands including «command», «set», and «ulimit».
-#
-#   Important for patching:
-#
-#   (2) This script targets any POSIX shell, so it avoids extensions provided
-#       by Bash, Ksh, etc; in particular arrays are avoided.
-#
-#       The "traditional" practice of packing multiple parameters into a
-#       space-separated string is a well documented source of bugs and security
-#       problems, so this is (mostly) avoided, by progressively accumulating
-#       options in "$@", and eventually passing that to Java.
-#
-#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-#       see the in-line comments for details.
-#
-#       There are tweaks for specific operating systems such as AIX, CygWin,
-#       Darwin, MinGW, and NonStop.
-#
-#   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-#       within the Gradle project.
-#
-#       You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
-    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
-    [ -h "$app_path" ]
-do
-    ls=$( ls -ld "$app_path" )
-    link=${ls#*' -> '}
-    case $link in             #(
-      /*)   app_path=$link ;; #(
-      *)    app_path=$APP_HOME$link ;;
-    esac
-done
-
-# This is normally unused
-# shellcheck disable=SC2034
-APP_BASE_NAME=${0##*/}
-# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
-    echo "$*"
-} >&2
-
-die () {
-    echo
-    echo "$*"
-    echo
-    exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in                #(
-  CYGWIN* )         cygwin=true  ;; #(
-  Darwin* )         darwin=true  ;; #(
-  MSYS* | MINGW* )  msys=true    ;; #(
-  NONSTOP* )        nonstop=true ;;
-esac
-
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
-    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
-        # IBM's JDK on AIX uses strange locations for the executables
-        JAVACMD=$JAVA_HOME/jre/sh/java
-    else
-        JAVACMD=$JAVA_HOME/bin/java
-    fi
-    if [ ! -x "$JAVACMD" ] ; then
-        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
-    fi
-else
-    JAVACMD=java
-    if ! command -v java >/dev/null 2>&1
-    then
-        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
-    fi
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
-    case $MAX_FD in #(
-      max*)
-        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC2039,SC3045
-        MAX_FD=$( ulimit -H -n ) ||
-            warn "Could not query maximum file descriptor limit"
-    esac
-    case $MAX_FD in  #(
-      '' | soft) :;; #(
-      *)
-        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC2039,SC3045
-        ulimit -n "$MAX_FD" ||
-            warn "Could not set maximum file descriptor limit to $MAX_FD"
-    esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-#   * args from the command line
-#   * the main class name
-#   * -classpath
-#   * -D...appname settings
-#   * --module-path (only if needed)
-#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
-    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
-
-    JAVACMD=$( cygpath --unix "$JAVACMD" )
-
-    # Now convert the arguments - kludge to limit ourselves to /bin/sh
-    for arg do
-        if
-            case $arg in                                #(
-              -*)   false ;;                            # don't mess with options #(
-              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
-                    [ -e "$t" ] ;;                      #(
-              *)    false ;;
-            esac
-        then
-            arg=$( cygpath --path --ignore --mixed "$arg" )
-        fi
-        # Roll the args list around exactly as many times as the number of
-        # args, so each arg winds up back in the position where it started, but
-        # possibly modified.
-        #
-        # NB: a `for` loop captures its iteration list before it begins, so
-        # changing the positional parameters here affects neither the number of
-        # iterations, nor the values presented in `arg`.
-        shift                   # remove old arg
-        set -- "$@" "$arg"      # push replacement arg
-    done
-fi
-
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
-# Collect all arguments for the java command:
-#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
-#     and any embedded shellness will be escaped.
-#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
-#     treated as '${Hostname}' itself on the command line.
-
-set -- \
-        "-Dorg.gradle.appname=$APP_BASE_NAME" \
-        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
-        "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
-    die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-#   set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
-        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
-        xargs -n1 |
-        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
-        tr '\n' ' '
-    )" '"$@"'
-
-exec "$JAVACMD" "$@"
ocaml-git.opam
new file mode 100644
index 0000000..4020949
--- /dev/null
+++ b/ocaml-git.opam
@@ -0,0 +1,24 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "Small OCaml Git library"
+depends: [
+  "ocaml" {>= "5.4"}
+  "dune" {>= "3.22"}
+  "alcotest"
+  "odoc" {with-doc}
+]
+build: [
+  ["dune" "subst"] {dev}
+  [
+    "dune"
+    "build"
+    "-p"
+    name
+    "-j"
+    jobs
+    "@install"
+    "@runtest" {with-test}
+    "@doc" {with-doc}
+  ]
+]
+x-maintenance-intent: ["(latest)"]
scripts/create-demo-fixture.sh
index 58e88b6..3aa0c94 100755
--- a/scripts/create-demo-fixture.sh
+++ b/scripts/create-demo-fixture.sh
@@ -16,7 +16,7 @@ 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.
+This repository is a deterministic fixture for ocaml-git tests.
 TXT
 mkdir -p "$REPO/src"
 cat > "$REPO/src/hello.txt" <<'TXT'
settings.gradle.kts
deleted file mode 100644
index 7d20ed2..0000000
--- a/settings.gradle.kts
+++ /dev/null
@@ -1,15 +0,0 @@
-pluginManagement {
-    repositories {
-        gradlePluginPortal()
-        mavenCentral()
-    }
-}
-
-dependencyResolutionManagement {
-    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
-    repositories {
-        mavenCentral()
-    }
-}
-
-rootProject.name = "kotlin-git"
src/dune
new file mode 100644
index 0000000..059b767
--- /dev/null
+++ b/src/dune
@@ -0,0 +1,8 @@
+(library
+ (name ocaml_git)
+ (public_name ocaml-git)
+ (foreign_stubs
+  (language c)
+  (names ocaml_git_stubs))
+ (c_library_flags -lgit2)
+ (libraries unix))
src/main/kotlin/kotlinx/git/Git.kt
deleted file mode 100644
index c6bc035..0000000
--- a/src/main/kotlin/kotlinx/git/Git.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-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)
-    }
-
-    fun shutdown() {
-        Libgit2.shutdown()
-    }
-}
src/main/kotlin/kotlinx/git/GitException.kt
deleted file mode 100644
index 56ab638..0000000
--- a/src/main/kotlin/kotlinx/git/GitException.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package kotlinx.git
-
-class GitException(message: String) : RuntimeException(message)
-
src/main/kotlin/kotlinx/git/Models.kt
deleted file mode 100644
index 3438730..0000000
--- a/src/main/kotlin/kotlinx/git/Models.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-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,
-)
-
-data class TreeListing(
-    val path: String,
-    val commit: Commit,
-    val entries: List<TreeEntry>,
-)
-
-data class Blob(
-    val path: String,
-    val id: Oid,
-    val bytes: ByteArray,
-) {
-    val isBinary: Boolean
-        get() = bytes.any { it == 0.toByte() }
-
-    fun text(): String = bytes.decodeToString()
-
-    override fun equals(other: Any?): Boolean =
-        other is Blob && path == other.path && id == other.id && bytes.contentEquals(other.bytes)
-
-    override fun hashCode(): Int {
-        var result = path.hashCode()
-        result = 31 * result + id.hashCode()
-        result = 31 * result + bytes.contentHashCode()
-        return result
-    }
-}
-
-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
deleted file mode 100644
index 98b102c..0000000
--- a/src/main/kotlin/kotlinx/git/Repository.kt
+++ /dev/null
@@ -1,516 +0,0 @@
-@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.readBytes
-import kotlinx.cinterop.reinterpret
-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_SORT_TIME
-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_lookup
-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_blob_free
-import kotlinx.git.internal.git2.git_blob_lookup
-import kotlinx.git.internal.git2.git_blob_rawcontent
-import kotlinx.git.internal.git2.git_blob_rawsize
-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_bare
-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_byindex
-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_entrycount
-import kotlinx.git.internal.git2.git_tree_free
-import kotlinx.git.internal.git2.git_tree_lookup
-import kotlinx.git.internal.git2.git_revwalk_free
-import kotlinx.git.internal.git2.git_revwalk_new
-import kotlinx.git.internal.git2.git_revwalk_next
-import kotlinx.git.internal.git2.git_revwalk_push
-import kotlinx.git.internal.git2.git_revwalk_sorting
-
-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 branchCommit(branchName: String): Commit = memScoped {
-        val ref = alloc<CPointerVar<git_reference>>()
-        Libgit2.check(git_branch_lookup(ref.ptr, requireOpen(), branchName, GIT_BRANCH_LOCAL), "git_branch_lookup")
-        try {
-            val target = git_reference_target(ref.value)
-                ?: throw GitException("Branch $branchName 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)
-        }
-    }
-
-    fun tree(path: String = "", commit: Commit = headCommit()): TreeListing = memScoped {
-        val tree = lookupTree(path, commit)
-        try {
-            val count = git_tree_entrycount(tree).toInt()
-            val entries = (0 until count).map { index ->
-                val entry = git_tree_entry_byindex(tree, index.convert())
-                    ?: throw GitException("Tree entry $index was unexpectedly null")
-                val id = git_tree_entry_id(entry) ?: throw GitException("Tree entry has no id")
-                TreeEntry(
-                    name = git_tree_entry_name(entry)?.toKString() ?: "",
-                    id = id.toOid(),
-                    kind = git_tree_entry_type(entry).toTreeEntryKind(),
-                )
-            }.sortedWith(compareBy<TreeEntry> { it.kind != TreeEntryKind.Tree }.thenBy { it.name })
-            TreeListing(path = path, commit = commit, entries = entries)
-        } finally {
-            git_tree_free(tree)
-        }
-    }
-
-    fun blob(path: String, commit: Commit = headCommit()): Blob = memScoped {
-        val entry = treeEntry(path, commit)
-        if (entry.kind != TreeEntryKind.Blob) {
-            throw GitException("$path is not a blob")
-        }
-
-        val oid = alloc<git_oid>()
-        oid.fromOid(entry.id)
-        val blob = alloc<CPointerVar<cnames.structs.git_blob>>()
-        Libgit2.check(git_blob_lookup(blob.ptr, requireOpen(), oid.ptr), "git_blob_lookup")
-        try {
-            val size = git_blob_rawsize(blob.value).toLong()
-            if (size > Int.MAX_VALUE) throw GitException("$path is too large to read")
-            val content = git_blob_rawcontent(blob.value)
-            val bytes = if (size == 0L || content == null) {
-                ByteArray(0)
-            } else {
-                content.reinterpret<ByteVar>().readBytes(size.toInt())
-            }
-            Blob(path = path, id = entry.id, bytes = bytes)
-        } finally {
-            git_blob_free(blob.value)
-        }
-    }
-
-    fun commits(branchName: String, limit: Int = 50): List<Commit> = memScoped {
-        val start = branchCommit(branchName)
-        val oid = alloc<git_oid>()
-        oid.fromOid(start.id)
-        val walker = alloc<CPointerVar<cnames.structs.git_revwalk>>()
-        Libgit2.check(git_revwalk_new(walker.ptr, requireOpen()), "git_revwalk_new")
-        try {
-            git_revwalk_sorting(walker.value, GIT_SORT_TIME)
-            Libgit2.check(git_revwalk_push(walker.value, oid.ptr), "git_revwalk_push")
-            val next = alloc<git_oid>()
-            val result = mutableListOf<Commit>()
-            while (result.size < limit) {
-                val code = git_revwalk_next(next.ptr, walker.value)
-                if (code == GIT_ITEROVER) break
-                Libgit2.check(code, "git_revwalk_next")
-                result += lookupCommit(next.ptr)
-            }
-            result
-        } finally {
-            git_revwalk_free(walker.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)
-        }
-    }
-
-    private fun lookupTree(path: String, commit: Commit): CPointer<git_tree> = 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 root = alloc<CPointerVar<git_tree>>()
-            Libgit2.check(git_commit_tree(root.ptr, gitCommit.value), "git_commit_tree")
-            if (path.isBlank()) return root.value ?: throw GitException("Commit has no tree")
-
-            try {
-                val entry = alloc<CPointerVar<git_tree_entry>>()
-                Libgit2.check(git_tree_entry_bypath(entry.ptr, root.value, path), "git_tree_entry_bypath")
-                try {
-                    if (git_tree_entry_type(entry.value) != GIT_OBJECT_TREE) {
-                        throw GitException("$path is not a tree")
-                    }
-                    val treeOid = git_tree_entry_id(entry.value) ?: throw GitException("Tree entry has no id")
-                    val tree = alloc<CPointerVar<git_tree>>()
-                    Libgit2.check(git_tree_lookup(tree.ptr, requireOpen(), treeOid), "git_tree_lookup")
-                    tree.value ?: throw GitException("Tree lookup returned null")
-                } finally {
-                    git_tree_entry_free(entry.value)
-                }
-            } finally {
-                git_tree_free(root.value)
-            }
-        } finally {
-            git_commit_free(gitCommit.value)
-        }
-    }
-
-    internal companion object {
-        fun open(path: String): Repository = memScoped {
-            val repo = alloc<CPointerVar<git_repository>>()
-            val code = git_repository_open(repo.ptr, path)
-            if (code < 0) {
-                Libgit2.check(git_repository_open_bare(repo.ptr, path), "git_repository_open_bare")
-            }
-            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
deleted file mode 100644
index dd0ed38..0000000
--- a/src/main/kotlin/kotlinx/git/internal/Libgit2.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-@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
deleted file mode 100644
index adf39b2..0000000
--- a/src/nativeInterop/cinterop/libgit2.def
+++ /dev/null
@@ -1,3 +0,0 @@
-package = kotlinx.git.internal.git2
-headers = git2.h
-linkerOpts.linux = -lgit2
src/ocaml_git.ml
new file mode 100644
index 0000000..6aa8429
--- /dev/null
+++ b/src/ocaml_git.ml
@@ -0,0 +1,273 @@
+type oid = string
+
+type signature = {
+  name : string;
+  email : string;
+  epoch_seconds : int;
+  timezone_offset_minutes : int;
+}
+
+type commit = {
+  id : oid;
+  summary : string;
+  message : string;
+  author : signature;
+  committer : signature;
+  parent_count : int;
+}
+
+type branch_type = Local | Remote
+
+type branch = {
+  name : string;
+  kind : branch_type;
+  is_head : bool;
+}
+
+type tree_entry_kind = Blob | Tree | Commit | Tag | Other
+
+type tree_entry = {
+  name : string;
+  id : oid;
+  kind : tree_entry_kind;
+}
+
+type tree_listing = {
+  path : string;
+  commit : commit;
+  entries : tree_entry list;
+}
+
+type blob = {
+  path : string;
+  id : oid;
+  bytes : string;
+}
+
+type status_flag =
+  | Current
+  | Index_new
+  | Index_modified
+  | Index_deleted
+  | Index_renamed
+  | Index_type_change
+  | Worktree_new
+  | Worktree_modified
+  | Worktree_deleted
+  | Worktree_type_change
+  | Worktree_renamed
+  | Worktree_unreadable
+  | Ignored
+  | Conflicted
+
+type status_entry = {
+  path : string;
+  flags : status_flag list;
+}
+
+exception Git_error of string
+
+let () = Callback.register_exception "ocaml_git_error" (Git_error "")
+
+type repo_handle
+type index_handle
+
+type t = {
+  handle : repo_handle;
+  mutable closed : bool;
+}
+
+type index = {
+  repo : t;
+  handle : index_handle;
+  mutable index_closed : bool;
+}
+
+type signature_raw = string * string * int * int
+type commit_raw = string * string * string * signature_raw * signature_raw * int
+type branch_raw = string * int * bool
+type tree_entry_raw = string * string * int
+type status_raw = string * int list
+
+external libgit2_init : unit -> unit = "kg_libgit2_init"
+external libgit2_shutdown : unit -> unit = "kg_libgit2_shutdown"
+external raw_open : string -> repo_handle = "kg_open"
+external raw_init : string -> bool -> repo_handle = "kg_init"
+external raw_discover : string -> string = "kg_discover"
+external raw_close : repo_handle -> unit = "kg_close"
+external raw_git_dir : repo_handle -> string = "kg_git_dir"
+external raw_workdir : repo_handle -> string option = "kg_workdir"
+external raw_is_bare : repo_handle -> bool = "kg_is_bare"
+external raw_is_empty : repo_handle -> bool = "kg_is_empty"
+external raw_head_name : repo_handle -> string = "kg_head_name"
+external raw_head_commit : repo_handle -> commit_raw = "kg_head_commit"
+external raw_branch_commit : repo_handle -> string -> commit_raw = "kg_branch_commit"
+external raw_branches : repo_handle -> branch_raw list = "kg_branches"
+external raw_tree_entry : repo_handle -> string -> string -> tree_entry_raw = "kg_tree_entry"
+external raw_tree : repo_handle -> string -> string -> tree_entry_raw list = "kg_tree"
+external raw_blob : repo_handle -> string -> string = "kg_blob"
+external raw_commits : repo_handle -> string -> int -> commit_raw list = "kg_commits"
+external raw_status : repo_handle -> status_raw list = "kg_status"
+external raw_index : repo_handle -> index_handle = "kg_index"
+external raw_index_size : index_handle -> int = "kg_index_size"
+external raw_index_contains : index_handle -> string -> bool = "kg_index_contains"
+external raw_index_add : index_handle -> string -> unit = "kg_index_add"
+external raw_index_remove : index_handle -> string -> unit = "kg_index_remove"
+external raw_index_write : index_handle -> unit = "kg_index_write"
+external raw_index_close : index_handle -> unit = "kg_index_close"
+
+let () = libgit2_init ()
+
+let require_open repo =
+  if repo.closed then raise (Git_error "Repository is closed")
+
+let require_index_open index =
+  if index.index_closed then raise (Git_error "Index is closed")
+
+let signature_of_raw (name, email, epoch_seconds, timezone_offset_minutes) =
+  { name; email; epoch_seconds; timezone_offset_minutes }
+
+let commit_of_raw (id, summary, message, author, committer, parent_count) =
+  { id; summary; message; author = signature_of_raw author; committer = signature_of_raw committer; parent_count }
+
+let branch_kind = function
+  | 0 -> Local
+  | _ -> Remote
+
+let branch_of_raw (name, kind, is_head) = { name; kind = branch_kind kind; is_head }
+
+let tree_entry_kind = function
+  | 0 -> Blob
+  | 1 -> Tree
+  | 2 -> Commit
+  | 3 -> Tag
+  | _ -> Other
+
+let tree_entry_of_raw (name, id, kind) = { name; id; kind = tree_entry_kind kind }
+
+let status_flag = function
+  | 0 -> Current
+  | 1 -> Index_new
+  | 2 -> Index_modified
+  | 3 -> Index_deleted
+  | 4 -> Index_renamed
+  | 5 -> Index_type_change
+  | 6 -> Worktree_new
+  | 7 -> Worktree_modified
+  | 8 -> Worktree_deleted
+  | 9 -> Worktree_type_change
+  | 10 -> Worktree_renamed
+  | 11 -> Worktree_unreadable
+  | 12 -> Ignored
+  | _ -> Conflicted
+
+let status_of_raw (path, flags) = { path; flags = List.map status_flag flags }
+
+let open_repo path = { handle = raw_open path; closed = false }
+let init ?(bare = false) path = { handle = raw_init path bare; closed = false }
+let discover = raw_discover
+
+let close repo =
+  if not repo.closed then raw_close repo.handle;
+  repo.closed <- true
+
+let with_repo path f =
+  let repo = open_repo path in
+  Fun.protect ~finally:(fun () -> close repo) (fun () -> f repo)
+
+let git_dir repo =
+  require_open repo;
+  raw_git_dir repo.handle
+
+let workdir repo =
+  require_open repo;
+  raw_workdir repo.handle
+
+let is_bare repo =
+  require_open repo;
+  raw_is_bare repo.handle
+
+let is_empty repo =
+  require_open repo;
+  raw_is_empty repo.handle
+
+let head_name repo =
+  require_open repo;
+  raw_head_name repo.handle
+
+let head_commit repo =
+  require_open repo;
+  raw_head_commit repo.handle |> commit_of_raw
+
+let branch_commit repo branch =
+  require_open repo;
+  raw_branch_commit repo.handle branch |> commit_of_raw
+
+let branches repo =
+  require_open repo;
+  raw_branches repo.handle |> List.rev_map branch_of_raw
+
+let status repo =
+  require_open repo;
+  raw_status repo.handle |> List.map status_of_raw
+
+let tree_entry ?commit repo path =
+  require_open repo;
+  let commit = match commit with Some commit -> commit | None -> head_commit repo in
+  raw_tree_entry repo.handle commit.id path |> tree_entry_of_raw
+
+let compare_entry a b =
+  match (a.kind = Tree, b.kind = Tree) with
+  | true, false -> -1
+  | false, true -> 1
+  | _ -> String.compare a.name b.name
+
+let tree ?commit ?(path = "") repo () =
+  require_open repo;
+  let commit = match commit with Some commit -> commit | None -> head_commit repo in
+  let entries = raw_tree repo.handle commit.id path |> List.map tree_entry_of_raw |> List.sort compare_entry in
+  { path; commit; entries }
+
+let blob ?commit repo path =
+  let entry = tree_entry ?commit repo path in
+  if entry.kind <> Blob then raise (Git_error (path ^ " is not a blob"));
+  { path; id = entry.id; bytes = raw_blob repo.handle entry.id }
+
+let commits ?(limit = 50) repo branch =
+  require_open repo;
+  raw_commits repo.handle branch limit |> List.map commit_of_raw
+
+let index repo =
+  require_open repo;
+  { repo; handle = raw_index repo.handle; index_closed = false }
+
+let index_size index =
+  require_index_open index;
+  raw_index_size index.handle
+
+let index_contains index path =
+  require_index_open index;
+  raw_index_contains index.handle path
+
+let index_add index path =
+  require_index_open index;
+  raw_index_add index.handle path;
+  index
+
+let index_remove index path =
+  require_index_open index;
+  raw_index_remove index.handle path;
+  index
+
+let index_write index =
+  require_index_open index;
+  raw_index_write index.handle;
+  index
+
+let close_index index =
+  if not index.index_closed then raw_index_close index.handle;
+  index.index_closed <- true
+
+let blob_is_binary blob = String.contains blob.bytes '\000'
+let blob_text blob = blob.bytes
+let shutdown () = libgit2_shutdown ()
src/ocaml_git.mli
new file mode 100644
index 0000000..58a9d78
--- /dev/null
+++ b/src/ocaml_git.mli
@@ -0,0 +1,103 @@
+type oid = string
+
+type signature = {
+  name : string;
+  email : string;
+  epoch_seconds : int;
+  timezone_offset_minutes : int;
+}
+
+type commit = {
+  id : oid;
+  summary : string;
+  message : string;
+  author : signature;
+  committer : signature;
+  parent_count : int;
+}
+
+type branch_type = Local | Remote
+
+type branch = {
+  name : string;
+  kind : branch_type;
+  is_head : bool;
+}
+
+type tree_entry_kind = Blob | Tree | Commit | Tag | Other
+
+type tree_entry = {
+  name : string;
+  id : oid;
+  kind : tree_entry_kind;
+}
+
+type tree_listing = {
+  path : string;
+  commit : commit;
+  entries : tree_entry list;
+}
+
+type blob = {
+  path : string;
+  id : oid;
+  bytes : string;
+}
+
+type status_flag =
+  | Current
+  | Index_new
+  | Index_modified
+  | Index_deleted
+  | Index_renamed
+  | Index_type_change
+  | Worktree_new
+  | Worktree_modified
+  | Worktree_deleted
+  | Worktree_type_change
+  | Worktree_renamed
+  | Worktree_unreadable
+  | Ignored
+  | Conflicted
+
+type status_entry = {
+  path : string;
+  flags : status_flag list;
+}
+
+exception Git_error of string
+
+type t
+type index
+
+val open_repo : string -> t
+val init : ?bare:bool -> string -> t
+val discover : string -> string
+val close : t -> unit
+val with_repo : string -> (t -> 'a) -> 'a
+
+val git_dir : t -> string
+val workdir : t -> string option
+val is_bare : t -> bool
+val is_empty : t -> bool
+val head_name : t -> string
+val head_commit : t -> commit
+val branch_commit : t -> string -> commit
+val branches : t -> branch list
+val status : t -> status_entry list
+val tree_entry : ?commit:commit -> t -> string -> tree_entry
+val tree : ?commit:commit -> ?path:string -> t -> unit -> tree_listing
+val blob : ?commit:commit -> t -> string -> blob
+val commits : ?limit:int -> t -> string -> commit list
+
+val index : t -> index
+val index_size : index -> int
+val index_contains : index -> string -> bool
+val index_add : index -> string -> index
+val index_remove : index -> string -> index
+val index_write : index -> index
+val close_index : index -> unit
+
+val blob_is_binary : blob -> bool
+val blob_text : blob -> string
+val shutdown : unit -> unit
src/ocaml_git_stubs.c
new file mode 100644
index 0000000..f91093b
--- /dev/null
+++ b/src/ocaml_git_stubs.c
@@ -0,0 +1,520 @@
+#include <caml/alloc.h>
+#include <caml/callback.h>
+#include <caml/custom.h>
+#include <caml/fail.h>
+#include <caml/memory.h>
+#include <caml/mlvalues.h>
+#include <git2.h>
+#include <stdint.h>
+#include <string.h>
+
+static void fail_git(const char *where, int code) {
+  const git_error *err = git_error_last();
+  char buffer[1024];
+  if (err && err->message) {
+    snprintf(buffer, sizeof(buffer), "%s: %s", where, err->message);
+  } else {
+    snprintf(buffer, sizeof(buffer), "%s failed with code %d", where, code);
+  }
+  caml_raise_with_string(*caml_named_value("ocaml_git_error"), buffer);
+}
+
+static void check(int code, const char *where) {
+  if (code < 0) fail_git(where, code);
+}
+
+static value copy_opt_string(const char *s) {
+  CAMLparam0();
+  CAMLlocal2(some, v);
+  if (!s) CAMLreturn(Val_int(0));
+  v = caml_copy_string(s);
+  some = caml_alloc(1, 0);
+  Store_field(some, 0, v);
+  CAMLreturn(some);
+}
+
+static value copy_oid(const git_oid *oid) {
+  char out[GIT_OID_HEXSZ + 1];
+  git_oid_tostr(out, sizeof(out), oid);
+  return caml_copy_string(out);
+}
+
+typedef struct {
+  git_repository *repo;
+} repo_handle;
+
+typedef struct {
+  git_index *index;
+} index_handle;
+
+static void finalize_repo(value v) {
+  repo_handle *h = Data_custom_val(v);
+  if (h->repo) {
+    git_repository_free(h->repo);
+    h->repo = NULL;
+  }
+}
+
+static void finalize_index(value v) {
+  index_handle *h = Data_custom_val(v);
+  if (h->index) {
+    git_index_free(h->index);
+    h->index = NULL;
+  }
+}
+
+static struct custom_operations repo_ops = {
+    "ocaml_git.repo",
+    finalize_repo,
+    custom_compare_default,
+    custom_hash_default,
+    custom_serialize_default,
+    custom_deserialize_default,
+    custom_compare_ext_default,
+    custom_fixed_length_default};
+
+static struct custom_operations index_ops = {
+    "ocaml_git.index",
+    finalize_index,
+    custom_compare_default,
+    custom_hash_default,
+    custom_serialize_default,
+    custom_deserialize_default,
+    custom_compare_ext_default,
+    custom_fixed_length_default};
+
+static git_repository *repo_val(value v) {
+  repo_handle *h = Data_custom_val(v);
+  if (!h->repo) caml_raise_with_string(*caml_named_value("ocaml_git_error"), "Repository is closed");
+  return h->repo;
+}
+
+static git_index *index_val(value v) {
+  index_handle *h = Data_custom_val(v);
+  if (!h->index) caml_raise_with_string(*caml_named_value("ocaml_git_error"), "Index is closed");
+  return h->index;
+}
+
+static value alloc_repo(git_repository *repo) {
+  CAMLparam0();
+  CAMLlocal1(v);
+  repo_handle *h;
+  v = caml_alloc_custom(&repo_ops, sizeof(repo_handle), 0, 1);
+  h = Data_custom_val(v);
+  h->repo = repo;
+  CAMLreturn(v);
+}
+
+static value alloc_index(git_index *index) {
+  CAMLparam0();
+  CAMLlocal1(v);
+  index_handle *h;
+  v = caml_alloc_custom(&index_ops, sizeof(index_handle), 0, 1);
+  h = Data_custom_val(v);
+  h->index = index;
+  CAMLreturn(v);
+}
+
+CAMLprim value kg_libgit2_init(value unit) {
+  CAMLparam1(unit);
+  git_libgit2_init();
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_libgit2_shutdown(value unit) {
+  CAMLparam1(unit);
+  git_libgit2_shutdown();
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_open(value path) {
+  CAMLparam1(path);
+  git_repository *repo = NULL;
+  int code = git_repository_open(&repo, String_val(path));
+  if (code < 0) code = git_repository_open_bare(&repo, String_val(path));
+  check(code, "git_repository_open");
+  CAMLreturn(alloc_repo(repo));
+}
+
+CAMLprim value kg_init(value path, value bare) {
+  CAMLparam2(path, bare);
+  git_repository *repo = NULL;
+  check(git_repository_init(&repo, String_val(path), Bool_val(bare)), "git_repository_init");
+  CAMLreturn(alloc_repo(repo));
+}
+
+CAMLprim value kg_discover(value start_path) {
+  CAMLparam1(start_path);
+  CAMLlocal1(result);
+  git_buf buf = GIT_BUF_INIT;
+  check(git_repository_discover(&buf, String_val(start_path), 0, NULL), "git_repository_discover");
+  result = caml_copy_string(buf.ptr ? buf.ptr : "");
+  git_buf_dispose(&buf);
+  CAMLreturn(result);
+}
+
+CAMLprim value kg_close(value repo_v) {
+  CAMLparam1(repo_v);
+  finalize_repo(repo_v);
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_git_dir(value repo_v) {
+  CAMLparam1(repo_v);
+  const char *path = git_repository_path(repo_val(repo_v));
+  CAMLreturn(caml_copy_string(path ? path : ""));
+}
+
+CAMLprim value kg_workdir(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLreturn(copy_opt_string(git_repository_workdir(repo_val(repo_v))));
+}
+
+CAMLprim value kg_is_bare(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLreturn(Val_bool(git_repository_is_bare(repo_val(repo_v))));
+}
+
+CAMLprim value kg_is_empty(value repo_v) {
+  CAMLparam1(repo_v);
+  int empty = git_repository_is_empty(repo_val(repo_v));
+  check(empty, "git_repository_is_empty");
+  CAMLreturn(Val_bool(empty));
+}
+
+CAMLprim value kg_head_name(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLlocal1(result);
+  git_reference *ref = NULL;
+  check(git_repository_head(&ref, repo_val(repo_v)), "git_repository_head");
+  result = caml_copy_string(git_reference_name(ref));
+  git_reference_free(ref);
+  CAMLreturn(result);
+}
+
+static value copy_signature(const git_signature *sig) {
+  CAMLparam0();
+  CAMLlocal1(v);
+  v = caml_alloc_tuple(4);
+  Store_field(v, 0, caml_copy_string(sig->name ? sig->name : ""));
+  Store_field(v, 1, caml_copy_string(sig->email ? sig->email : ""));
+  Store_field(v, 2, Val_int((int)sig->when.time));
+  Store_field(v, 3, Val_int(sig->when.offset));
+  CAMLreturn(v);
+}
+
+static value copy_commit(git_commit *commit) {
+  CAMLparam0();
+  CAMLlocal4(v, author, committer, id);
+  v = caml_alloc_tuple(6);
+  id = copy_oid(git_commit_id(commit));
+  author = copy_signature(git_commit_author(commit));
+  committer = copy_signature(git_commit_committer(commit));
+  Store_field(v, 0, id);
+  Store_field(v, 1, caml_copy_string(git_commit_summary(commit) ? git_commit_summary(commit) : ""));
+  Store_field(v, 2, caml_copy_string(git_commit_message(commit) ? git_commit_message(commit) : ""));
+  Store_field(v, 3, author);
+  Store_field(v, 4, committer);
+  Store_field(v, 5, Val_int(git_commit_parentcount(commit)));
+  CAMLreturn(v);
+}
+
+static value lookup_commit_value(git_repository *repo, const git_oid *oid) {
+  CAMLparam0();
+  CAMLlocal1(v);
+  git_commit *commit = NULL;
+  check(git_commit_lookup(&commit, repo, oid), "git_commit_lookup");
+  v = copy_commit(commit);
+  git_commit_free(commit);
+  CAMLreturn(v);
+}
+
+CAMLprim value kg_head_commit(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLlocal1(result);
+  git_reference *ref = NULL;
+  const git_oid *oid;
+  check(git_repository_head(&ref, repo_val(repo_v)), "git_repository_head");
+  oid = git_reference_target(ref);
+  if (!oid) {
+    git_reference_free(ref);
+    caml_raise_with_string(*caml_named_value("ocaml_git_error"), "HEAD does not point to an object id");
+  }
+  result = lookup_commit_value(repo_val(repo_v), oid);
+  git_reference_free(ref);
+  CAMLreturn(result);
+}
+
+CAMLprim value kg_branch_commit(value repo_v, value branch_name) {
+  CAMLparam2(repo_v, branch_name);
+  CAMLlocal1(result);
+  git_reference *ref = NULL;
+  const git_oid *oid;
+  check(git_branch_lookup(&ref, repo_val(repo_v), String_val(branch_name), GIT_BRANCH_LOCAL), "git_branch_lookup");
+  oid = git_reference_target(ref);
+  if (!oid) {
+    git_reference_free(ref);
+    caml_raise_with_string(*caml_named_value("ocaml_git_error"), "Branch does not point to an object id");
+  }
+  result = lookup_commit_value(repo_val(repo_v), oid);
+  git_reference_free(ref);
+  CAMLreturn(result);
+}
+
+static value cons(value head, value tail) {
+  CAMLparam2(head, tail);
+  CAMLlocal1(cell);
+  cell = caml_alloc(2, 0);
+  Store_field(cell, 0, head);
+  Store_field(cell, 1, tail);
+  CAMLreturn(cell);
+}
+
+CAMLprim value kg_branches(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLlocal3(list, item, name_v);
+  git_branch_iterator *iter = NULL;
+  git_reference *ref = NULL;
+  git_branch_t type;
+  const char *name = NULL;
+  int code;
+  list = Val_emptylist;
+  check(git_branch_iterator_new(&iter, repo_val(repo_v), GIT_BRANCH_ALL), "git_branch_iterator_new");
+  while ((code = git_branch_next(&ref, &type, iter)) == 0) {
+    check(git_branch_name(&name, ref), "git_branch_name");
+    item = caml_alloc_tuple(3);
+    name_v = caml_copy_string(name ? name : "");
+    Store_field(item, 0, name_v);
+    Store_field(item, 1, Val_int(type == GIT_BRANCH_REMOTE ? 1 : 0));
+    Store_field(item, 2, Val_bool(git_branch_is_head(ref)));
+    list = cons(item, list);
+    git_reference_free(ref);
+  }
+  git_branch_iterator_free(iter);
+  if (code != GIT_ITEROVER) check(code, "git_branch_next");
+  CAMLreturn(list);
+}
+
+static int kind_tag(git_object_t type) {
+  switch (type) {
+  case GIT_OBJECT_BLOB: return 0;
+  case GIT_OBJECT_TREE: return 1;
+  case GIT_OBJECT_COMMIT: return 2;
+  case GIT_OBJECT_TAG: return 3;
+  default: return 4;
+  }
+}
+
+static value copy_tree_entry(const git_tree_entry *entry, const char *fallback_name) {
+  CAMLparam0();
+  CAMLlocal1(v);
+  const git_oid *oid = git_tree_entry_id(entry);
+  const char *name = git_tree_entry_name(entry);
+  v = caml_alloc_tuple(3);
+  Store_field(v, 0, caml_copy_string(name ? name : fallback_name));
+  Store_field(v, 1, copy_oid(oid));
+  Store_field(v, 2, Val_int(kind_tag(git_tree_entry_type(entry))));
+  CAMLreturn(v);
+}
+
+static void oid_from_string(git_oid *oid, value oid_v) {
+  check(git_oid_fromstr(oid, String_val(oid_v)), "git_oid_fromstr");
+}
+
+static git_commit *commit_from_oid_value(git_repository *repo, value oid_v) {
+  git_oid oid;
+  git_commit *commit = NULL;
+  oid_from_string(&oid, oid_v);
+  check(git_commit_lookup(&commit, repo, &oid), "git_commit_lookup");
+  return commit;
+}
+
+static git_tree *root_tree_from_commit(git_commit *commit) {
+  git_tree *tree = NULL;
+  check(git_commit_tree(&tree, commit), "git_commit_tree");
+  return tree;
+}
+
+CAMLprim value kg_tree_entry(value repo_v, value commit_oid_v, value path_v) {
+  CAMLparam3(repo_v, commit_oid_v, path_v);
+  CAMLlocal1(result);
+  git_commit *commit = commit_from_oid_value(repo_val(repo_v), commit_oid_v);
+  git_tree *tree = root_tree_from_commit(commit);
+  git_tree_entry *entry = NULL;
+  check(git_tree_entry_bypath(&entry, tree, String_val(path_v)), "git_tree_entry_bypath");
+  result = copy_tree_entry(entry, String_val(path_v));
+  git_tree_entry_free(entry);
+  git_tree_free(tree);
+  git_commit_free(commit);
+  CAMLreturn(result);
+}
+
+CAMLprim value kg_tree(value repo_v, value commit_oid_v, value path_v) {
+  CAMLparam3(repo_v, commit_oid_v, path_v);
+  CAMLlocal3(list, item, result);
+  git_commit *commit = commit_from_oid_value(repo_val(repo_v), commit_oid_v);
+  git_tree *root = root_tree_from_commit(commit);
+  git_tree *tree = root;
+  git_tree_entry *subentry = NULL;
+  size_t count, i;
+  list = Val_emptylist;
+  if (caml_string_length(path_v) > 0) {
+    check(git_tree_entry_bypath(&subentry, root, String_val(path_v)), "git_tree_entry_bypath");
+    if (git_tree_entry_type(subentry) != GIT_OBJECT_TREE) {
+      git_tree_entry_free(subentry);
+      git_tree_free(root);
+      git_commit_free(commit);
+      caml_raise_with_string(*caml_named_value("ocaml_git_error"), "path is not a tree");
+    }
+    check(git_tree_lookup(&tree, repo_val(repo_v), git_tree_entry_id(subentry)), "git_tree_lookup");
+    git_tree_entry_free(subentry);
+  }
+  count = git_tree_entrycount(tree);
+  for (i = count; i > 0; i--) {
+    const git_tree_entry *entry = git_tree_entry_byindex(tree, i - 1);
+    item = copy_tree_entry(entry, "");
+    list = cons(item, list);
+  }
+  result = list;
+  if (tree != root) git_tree_free(tree);
+  git_tree_free(root);
+  git_commit_free(commit);
+  CAMLreturn(result);
+}
+
+CAMLprim value kg_blob(value repo_v, value oid_v) {
+  CAMLparam2(repo_v, oid_v);
+  CAMLlocal2(result, bytes);
+  git_oid oid;
+  git_blob *blob = NULL;
+  const void *content;
+  git_object_size_t size;
+  oid_from_string(&oid, oid_v);
+  check(git_blob_lookup(&blob, repo_val(repo_v), &oid), "git_blob_lookup");
+  size = git_blob_rawsize(blob);
+  content = git_blob_rawcontent(blob);
+  bytes = caml_alloc_string((mlsize_t)size);
+  if (size > 0 && content) memcpy(Bytes_val(bytes), content, (size_t)size);
+  result = bytes;
+  git_blob_free(blob);
+  CAMLreturn(result);
+}
+
+CAMLprim value kg_commits(value repo_v, value branch_name_v, value limit_v) {
+  CAMLparam3(repo_v, branch_name_v, limit_v);
+  CAMLlocal3(list, commit_v, result);
+  git_reference *ref = NULL;
+  const git_oid *start_oid;
+  git_revwalk *walker = NULL;
+  git_oid oid;
+  int limit = Int_val(limit_v);
+  int count = 0;
+  list = Val_emptylist;
+  check(git_branch_lookup(&ref, repo_val(repo_v), String_val(branch_name_v), GIT_BRANCH_LOCAL), "git_branch_lookup");
+  start_oid = git_reference_target(ref);
+  check(git_revwalk_new(&walker, repo_val(repo_v)), "git_revwalk_new");
+  git_revwalk_sorting(walker, GIT_SORT_TIME);
+  check(git_revwalk_push(walker, start_oid), "git_revwalk_push");
+  while (count < limit && git_revwalk_next(&oid, walker) == 0) {
+    commit_v = lookup_commit_value(repo_val(repo_v), &oid);
+    list = cons(commit_v, list);
+    count++;
+  }
+  git_revwalk_free(walker);
+  git_reference_free(ref);
+  result = Val_emptylist;
+  while (list != Val_emptylist) {
+    result = cons(Field(list, 0), result);
+    list = Field(list, 1);
+  }
+  CAMLreturn(result);
+}
+
+static value flags_from_status(unsigned int status) {
+  CAMLparam0();
+  CAMLlocal1(list);
+  list = Val_emptylist;
+  if (status == GIT_STATUS_CURRENT) list = cons(Val_int(0), list);
+  if (status & GIT_STATUS_INDEX_NEW) list = cons(Val_int(1), list);
+  if (status & GIT_STATUS_INDEX_MODIFIED) list = cons(Val_int(2), list);
+  if (status & GIT_STATUS_INDEX_DELETED) list = cons(Val_int(3), list);
+  if (status & GIT_STATUS_INDEX_RENAMED) list = cons(Val_int(4), list);
+  if (status & GIT_STATUS_INDEX_TYPECHANGE) list = cons(Val_int(5), list);
+  if (status & GIT_STATUS_WT_NEW) list = cons(Val_int(6), list);
+  if (status & GIT_STATUS_WT_MODIFIED) list = cons(Val_int(7), list);
+  if (status & GIT_STATUS_WT_DELETED) list = cons(Val_int(8), list);
+  if (status & GIT_STATUS_WT_TYPECHANGE) list = cons(Val_int(9), list);
+  if (status & GIT_STATUS_WT_RENAMED) list = cons(Val_int(10), list);
+  if (status & GIT_STATUS_WT_UNREADABLE) list = cons(Val_int(11), list);
+  if (status & GIT_STATUS_IGNORED) list = cons(Val_int(12), list);
+  if (status & GIT_STATUS_CONFLICTED) list = cons(Val_int(13), list);
+  CAMLreturn(list);
+}
+
+CAMLprim value kg_status(value repo_v) {
+  CAMLparam1(repo_v);
+  CAMLlocal4(list, item, path_v, flags);
+  git_status_options opts;
+  git_status_list *statuses = NULL;
+  size_t count, i;
+  check(git_status_options_init(&opts, GIT_STATUS_OPTIONS_VERSION), "git_status_options_init");
+  opts.flags = GIT_STATUS_OPT_DEFAULTS;
+  check(git_status_list_new(&statuses, repo_val(repo_v), &opts), "git_status_list_new");
+  count = git_status_list_entrycount(statuses);
+  list = Val_emptylist;
+  for (i = count; i > 0; i--) {
+    const git_status_entry *entry = git_status_byindex(statuses, i - 1);
+    const git_diff_delta *delta = entry->index_to_workdir ? entry->index_to_workdir : entry->head_to_index;
+    const char *path = "";
+    if (delta) path = delta->new_file.path ? delta->new_file.path : delta->old_file.path;
+    item = caml_alloc_tuple(2);
+    path_v = caml_copy_string(path ? path : "");
+    flags = flags_from_status(entry->status);
+    Store_field(item, 0, path_v);
+    Store_field(item, 1, flags);
+    list = cons(item, list);
+  }
+  git_status_list_free(statuses);
+  CAMLreturn(list);
+}
+
+CAMLprim value kg_index(value repo_v) {
+  CAMLparam1(repo_v);
+  git_index *index = NULL;
+  check(git_repository_index(&index, repo_val(repo_v)), "git_repository_index");
+  CAMLreturn(alloc_index(index));
+}
+
+CAMLprim value kg_index_size(value index_v) {
+  CAMLparam1(index_v);
+  CAMLreturn(Val_int(git_index_entrycount(index_val(index_v))));
+}
+
+CAMLprim value kg_index_contains(value index_v, value path_v) {
+  CAMLparam2(index_v, path_v);
+  CAMLreturn(Val_bool(git_index_get_bypath(index_val(index_v), String_val(path_v), 0) != NULL));
+}
+
+CAMLprim value kg_index_add(value index_v, value path_v) {
+  CAMLparam2(index_v, path_v);
+  check(git_index_add_bypath(index_val(index_v), String_val(path_v)), "git_index_add_bypath");
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_index_remove(value index_v, value path_v) {
+  CAMLparam2(index_v, path_v);
+  check(git_index_remove_bypath(index_val(index_v), String_val(path_v)), "git_index_remove_bypath");
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_index_write(value index_v) {
+  CAMLparam1(index_v);
+  check(git_index_write(index_val(index_v)), "git_index_write");
+  CAMLreturn(Val_unit);
+}
+
+CAMLprim value kg_index_close(value index_v) {
+  CAMLparam1(index_v);
+  finalize_index(index_v);
+  CAMLreturn(Val_unit);
+}
src/test/kotlin/TestMain.kt
deleted file mode 100644
index e4431a9..0000000
--- a/src/test/kotlin/TestMain.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-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,
-        ::opensBareRepository,
-        ::discoversRepositoryFromChild,
-        ::readsHeadCommit,
-        ::listsBranches,
-        ::readsBranchCommit,
-        ::looksUpTreeEntries,
-        ::listsTrees,
-        ::readsBlobs,
-        ::walksCommitHistory,
-        ::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 opensBareRepository() {
-    val path = "$WorkRoot/bare-repo.git"
-    sh("git clone --bare $RepoPath $path >/dev/null 2>&1")
-    Git.open(path).use { repo ->
-        check(repo.isBare) { "cloned repo should be bare" }
-        check(repo.workdir == null) { "bare repo should not have a workdir: ${repo.workdir}" }
-        check(repo.headCommit().summary == "Update readme") { "unexpected bare HEAD ${repo.headCommit()}" }
-        check(repo.tree("").entries.any { it.name == "README.md" }) { "bare tree should include README" }
-    }
-}
-
-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 readsBranchCommit() {
-    Git.open(RepoPath).use { repo ->
-        val commit = repo.branchCommit("main")
-        check(commit.summary == "Update readme") { "unexpected main commit ${commit.summary}" }
-    }
-}
-
-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 listsTrees() {
-    Git.open(RepoPath).use { repo ->
-        val root = repo.tree("")
-        check(root.entries.any { it.name == "README.md" && it.kind == TreeEntryKind.Blob }) { "missing README in $root" }
-        check(root.entries.any { it.name == "src" && it.kind == TreeEntryKind.Tree }) { "missing src in $root" }
-
-        val src = repo.tree("src")
-        check(src.entries.any { it.name == "hello.txt" && it.kind == TreeEntryKind.Blob }) { "missing src/hello.txt in $src" }
-    }
-}
-
-private fun readsBlobs() {
-    Git.open(RepoPath).use { repo ->
-        val blob = repo.blob("README.md")
-        check(!blob.isBinary) { "README should be text" }
-        check(blob.text().contains("deterministic fixture")) { "unexpected README content" }
-    }
-}
-
-private fun walksCommitHistory() {
-    Git.open(RepoPath).use { repo ->
-        val commits = repo.commits("main")
-        check(commits.size == 2) { "unexpected history $commits" }
-        check(commits.first().summary == "Update readme") { "unexpected first commit ${commits.first()}" }
-        check(commits.last().summary == "Initial fixture commit") { "unexpected last commit ${commits.last()}" }
-    }
-}
-
-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()
-    }
-}
test/dune
new file mode 100644
index 0000000..1965d17
--- /dev/null
+++ b/test/dune
@@ -0,0 +1,13 @@
+(executable
+ (name test_ocaml_git)
+ (libraries alcotest ocaml-git unix))
+
+(rule
+ (alias runtest)
+ (deps
+  test_ocaml_git.exe
+  (source_tree ../fixtures))
+ (action
+  (chdir
+   ..
+   (run test/test_ocaml_git.exe))))
test/test_ocaml_git.ml
new file mode 100644
index 0000000..b4f2583
--- /dev/null
+++ b/test/test_ocaml_git.ml
@@ -0,0 +1,156 @@
+open Ocaml_git
+
+let work_root = ".build/test-work"
+let repo_path = work_root ^ "/demo-repo"
+
+let sh command =
+  match Sys.command command with
+  | 0 -> ()
+  | code -> Alcotest.failf "command failed with %d: %s" code command
+
+let reset_fixture () =
+  sh ("rm -rf " ^ work_root ^ " && mkdir -p " ^ work_root ^ " && tar -xzf fixtures/demo-repo.tar.gz -C " ^ work_root)
+
+let with_fixture f =
+  reset_fixture ();
+  f ()
+
+let check_suffix label suffix value =
+  Alcotest.(check bool) label true (String.ends_with ~suffix value)
+
+let opens_repository () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  check_suffix "workdir" "demo-repo/" (Option.get (workdir repo));
+  check_suffix "git_dir" "demo-repo/.git/" (git_dir repo);
+  Alcotest.(check bool) "not bare" false (is_bare repo);
+  Alcotest.(check bool) "not empty" false (is_empty repo);
+  Alcotest.(check string) "head" "refs/heads/main" (head_name repo)
+
+let opens_bare_repository () = with_fixture @@ fun () ->
+  let path = work_root ^ "/bare-repo.git" in
+  sh ("git clone --bare " ^ repo_path ^ " " ^ path ^ " >/dev/null 2>&1");
+  with_repo path @@ fun repo ->
+  Alcotest.(check bool) "bare" true (is_bare repo);
+  Alcotest.(check bool) "no workdir" true (Option.is_none (workdir repo));
+  Alcotest.(check string) "summary" "Update readme" (head_commit repo).summary;
+  Alcotest.(check bool) "has readme" true (List.exists (fun e -> e.name = "README.md") (tree repo ()).entries)
+
+let discovers_repository_from_child () = with_fixture @@ fun () ->
+  check_suffix "discover" "demo-repo/.git/" (discover (repo_path ^ "/src"))
+
+let reads_head_commit () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let commit = head_commit repo in
+  Alcotest.(check int) "oid length" 40 (String.length commit.id);
+  Alcotest.(check string) "summary" "Update readme" commit.summary;
+  Alcotest.(check bool) "message" true (String.contains commit.message 'U');
+  Alcotest.(check string) "author" "Fixture Author" commit.author.name;
+  Alcotest.(check string) "email" "fixture@example.com" commit.author.email;
+  Alcotest.(check int) "parents" 1 commit.parent_count
+
+let lists_branches () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let branches : branch list = branches repo in
+  let names = List.map (fun (b : branch) -> b.name) branches in
+  Alcotest.(check bool) "main" true (List.mem "main" names);
+  Alcotest.(check bool) "feature" true (List.mem "feature/demo" names);
+  Alcotest.(check bool) "main head" true (List.find (fun (b : branch) -> b.name = "main") branches).is_head;
+  Alcotest.(check bool) "local" true (List.for_all (fun (b : branch) -> b.kind = Local) branches)
+
+let reads_branch_commit () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  Alcotest.(check string) "summary" "Update readme" (branch_commit repo "main").summary
+
+let looks_up_tree_entries () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let readme = tree_entry repo "README.md" in
+  Alcotest.(check string) "name" "README.md" readme.name;
+  Alcotest.(check bool) "blob" true (readme.kind = Blob);
+  Alcotest.(check int) "oid" 40 (String.length readme.id);
+  Alcotest.(check bool) "src tree" true ((tree_entry repo "src").kind = Tree)
+
+let lists_trees () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let root = tree repo () in
+  Alcotest.(check bool) "readme" true (List.exists (fun e -> e.name = "README.md" && e.kind = Blob) root.entries);
+  Alcotest.(check bool) "src" true (List.exists (fun e -> e.name = "src" && e.kind = Tree) root.entries);
+  let src = tree ~path:"src" repo () in
+  Alcotest.(check bool) "hello" true (List.exists (fun e -> e.name = "hello.txt" && e.kind = Blob) src.entries)
+
+let reads_blobs () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let blob = blob repo "README.md" in
+  Alcotest.(check bool) "text" false (blob_is_binary blob);
+  Alcotest.(check bool) "content" true (String.contains (blob_text blob) 'd')
+
+let walks_commit_history () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  let commits = commits repo "main" in
+  Alcotest.(check int) "count" 2 (List.length commits);
+  Alcotest.(check string) "first" "Update readme" (List.hd commits).summary;
+  Alcotest.(check string) "last" "Initial fixture commit" (List.hd (List.rev commits)).summary
+
+let reports_clean_status () = with_fixture @@ fun () ->
+  with_repo repo_path @@ fun repo ->
+  Alcotest.(check int) "clean" 0 (List.length (status repo))
+
+let reports_worktree_status () = with_fixture @@ fun () ->
+  sh ("printf '\\nlocal edit\\n' >> " ^ repo_path ^ "/README.md");
+  sh ("printf 'notes\\n' > " ^ repo_path ^ "/notes.txt");
+  with_repo repo_path @@ fun repo ->
+  let by_path path = List.find (fun s -> s.path = path) (status repo) in
+  Alcotest.(check bool) "modified" true (List.mem Worktree_modified (by_path "README.md").flags);
+  Alcotest.(check bool) "new" true (List.mem Worktree_new (by_path "notes.txt").flags)
+
+let updates_index () = with_fixture @@ fun () ->
+  sh ("printf 'notes\\n' > " ^ repo_path ^ "/notes.txt");
+  with_repo repo_path @@ fun repo ->
+  let idx = index repo in
+  Fun.protect
+    ~finally:(fun () -> close_index idx)
+    (fun () ->
+      Alcotest.(check bool) "readme indexed" true (index_contains idx "README.md");
+      let original_size = index_size idx in
+      ignore (index_add idx "notes.txt" |> index_write);
+      Alcotest.(check bool) "notes indexed" true (index_contains idx "notes.txt");
+      Alcotest.(check int) "size" (original_size + 1) (index_size idx));
+  let notes = List.find (fun s -> s.path = "notes.txt") (status repo) in
+  Alcotest.(check bool) "staged" true (List.mem Index_new notes.flags)
+
+let initializes_repository () = with_fixture @@ fun () ->
+  let path = work_root ^ "/new-repo" in
+  sh ("rm -rf " ^ path ^ " && mkdir -p " ^ path);
+  let repo = init path in
+  Fun.protect ~finally:(fun () -> close repo) @@ fun () ->
+  Alcotest.(check bool) "not bare" false (is_bare repo);
+  Alcotest.(check bool) "empty" true (is_empty repo);
+  check_suffix "new workdir" "new-repo/" (Option.get (workdir repo))
+
+let rejects_missing_repository () = with_fixture @@ fun () ->
+  Alcotest.(check bool) "missing" true
+    (match open_repo (work_root ^ "/missing") with
+     | _ -> false
+     | exception Git_error _ -> true)
+
+let () =
+  Alcotest.run "ocaml-git"
+    [
+      ( "repository",
+        [
+          Alcotest.test_case "opens repository" `Quick opens_repository;
+          Alcotest.test_case "opens bare repository" `Quick opens_bare_repository;
+          Alcotest.test_case "discovers repository from child" `Quick discovers_repository_from_child;
+          Alcotest.test_case "reads head commit" `Quick reads_head_commit;
+          Alcotest.test_case "lists branches" `Quick lists_branches;
+          Alcotest.test_case "reads branch commit" `Quick reads_branch_commit;
+          Alcotest.test_case "looks up tree entries" `Quick looks_up_tree_entries;
+          Alcotest.test_case "lists trees" `Quick lists_trees;
+          Alcotest.test_case "reads blobs" `Quick reads_blobs;
+          Alcotest.test_case "walks commit history" `Quick walks_commit_history;
+          Alcotest.test_case "reports clean status" `Quick reports_clean_status;
+          Alcotest.test_case "reports worktree status" `Quick reports_worktree_status;
+          Alcotest.test_case "updates index" `Quick updates_index;
+          Alcotest.test_case "initializes repository" `Quick initializes_repository;
+          Alcotest.test_case "rejects missing repository" `Quick rejects_missing_repository;
+        ] );
+    ]