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