Repositories / ocaml-git.git

ocaml-git.git

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

Branch

Add read APIs for repository browsing

Expose branch commit lookup, tree listing, blob reads, and commit walks for read-only repository viewers.

Support opening bare repositories via libgit2's bare open path and cover the behavior with fixture tests.
Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-19 06:43:08 -0400
Commit
b881a34d9b50d76f57a5c21c9f5f8e94109331ed
src/main/kotlin/kotlinx/git/Git.kt
index 9dca139..c6bc035 100644
--- a/src/main/kotlin/kotlinx/git/Git.kt
+++ b/src/main/kotlin/kotlinx/git/Git.kt
@@ -14,5 +14,8 @@ object Git {
     fun discover(startPath: String): String = Libgit2.ensureInitialized {
         Repository.discover(startPath)
     }
-}
 
+    fun shutdown() {
+        Libgit2.shutdown()
+    }
+}
src/main/kotlin/kotlinx/git/Models.kt
index 74b48e0..3438730 100644
--- a/src/main/kotlin/kotlinx/git/Models.kt
+++ b/src/main/kotlin/kotlinx/git/Models.kt
@@ -24,6 +24,33 @@ data class Commit(
     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,
@@ -70,4 +97,3 @@ data class StatusEntry(
     val path: String,
     val flags: Set<StatusFlag>,
 )
-
src/main/kotlin/kotlinx/git/Repository.kt
index 124672a..98b102c 100644
--- a/src/main/kotlin/kotlinx/git/Repository.kt
+++ b/src/main/kotlin/kotlinx/git/Repository.kt
@@ -24,6 +24,8 @@ 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
@@ -35,6 +37,7 @@ 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
@@ -54,9 +57,14 @@ 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
@@ -85,6 +93,7 @@ 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
@@ -97,11 +106,19 @@ 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>?,
@@ -139,6 +156,18 @@ class Repository internal constructor(
         }
     }
 
+    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")
@@ -224,6 +253,74 @@ class Repository internal constructor(
         }
     }
 
+    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
@@ -242,10 +339,45 @@ class Repository internal constructor(
         }
     }
 
+    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>>()
-            Libgit2.check(git_repository_open(repo.ptr, path), "git_repository_open")
+            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)
         }
 
src/test/kotlin/TestMain.kt
index 7135519..e4431a9 100644
--- a/src/test/kotlin/TestMain.kt
+++ b/src/test/kotlin/TestMain.kt
@@ -12,10 +12,15 @@ fun main(args: Array<String>) {
     args.size
     val tests = listOf(
         ::opensRepository,
+        ::opensBareRepository,
         ::discoversRepositoryFromChild,
         ::readsHeadCommit,
         ::listsBranches,
+        ::readsBranchCommit,
         ::looksUpTreeEntries,
+        ::listsTrees,
+        ::readsBlobs,
+        ::walksCommitHistory,
         ::reportsCleanStatus,
         ::reportsWorktreeStatus,
         ::updatesIndex,
@@ -49,6 +54,17 @@ private fun opensRepository() {
     }
 }
 
+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" }
@@ -77,6 +93,13 @@ private fun listsBranches() {
     }
 }
 
+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")
@@ -89,6 +112,34 @@ private fun looksUpTreeEntries() {
     }
 }
 
+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()}" }