Repositories / ocaml-git.git
ocaml-git.git
Clone (read-only): git clone http://git.guha-anderson.com/git/ocaml-git.git
@@ -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:
@@ -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.
@@ -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") -}
@@ -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))
@@ -1,2 +0,0 @@ -kotlin.code.style=official -kotlin.native.ignoreDisabledTargets=true
@@ -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
@@ -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" "$@"
@@ -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)"]
@@ -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'
@@ -1,15 +0,0 @@ -pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenCentral() - } -} - -rootProject.name = "kotlin-git"
@@ -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))
@@ -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() - } -}
@@ -1,4 +0,0 @@ -package kotlinx.git - -class GitException(message: String) : RuntimeException(message) -
@@ -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>, -)
@@ -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 -}
@@ -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"}") - } -}
@@ -1,3 +0,0 @@ -package = kotlinx.git.internal.git2 -headers = git2.h -linkerOpts.linux = -lgit2
@@ -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 ()
@@ -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
@@ -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); +}
@@ -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() - } -}
@@ -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))))
@@ -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; + ] ); + ]