Repositories / ocaml-git.git
ocaml-git.git
Clone (read-only): git clone http://git.guha-anderson.com/git/ocaml-git.git
@@ -14,5 +14,8 @@ object Git { fun discover(startPath: String): String = Libgit2.ensureInitialized { Repository.discover(startPath) } -} + fun shutdown() { + Libgit2.shutdown() + } +}
@@ -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>, ) -
@@ -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) }
@@ -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()}" }