commit b161f2a059951ec8d99dad65069d0e9cb1fcce53 Author: Axel Meyer Date: Sun May 31 01:15:30 2026 +0200 lib-core.git v0.1.0: native libgit2 wrapper Initial release. Statically vendors libgit2 v1.7.2 (HTTPS-only via WinHTTP on Windows, no SSH dependency); exposes synchronous clone, checkout, fetch, describe, has_ref, is_repo, tags plus an async start_ls_remote_tags / is_done / take_result triple routed through the engine async-pool. The native-lib loader resolves engine services through the sporel_engine_api struct passed to sporel_lib_init, so this lib does not statically depend on internal engine symbols beyond the Lua C-API re-exports. Smoke-tested against a public Gitea repo: clone over HTTPS succeeds, async ls-remote returns the tag list, all C-allocations clean up via git_*_free / git_buf_dispose. First clean build of libgit2 takes ~5-15 min on a typical machine; cached afterwards. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b90d35e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,110 @@ +cmake_minimum_required(VERSION 3.20) +project(sporel_lib_core_git LANGUAGES C) + +# ----------------------------------------------------------------------------- +# Vendor libgit2 v1.7.2 — static, HTTPS-only, no SSH, no CLI, no tests. +# HTTPS backend: WinHTTP on Windows; libgit2 picks it automatically when +# USE_HTTPS=ON and BUILD_SHARED_LIBS=OFF. See ADR-0047. +# First fetch + build takes ~5-15 min; subsequent builds are fast. +# ----------------------------------------------------------------------------- +include(FetchContent) +set(BUILD_CLI OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(USE_SSH OFF CACHE BOOL "" FORCE) # HTTPS-only per spec +set(USE_HTTPS ON CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) # static +set(REGEX_BACKEND builtin CACHE STRING "" FORCE) + +FetchContent_Declare( + libgit2 + GIT_REPOSITORY https://github.com/libgit2/libgit2.git + GIT_TAG v1.7.2) +FetchContent_MakeAvailable(libgit2) + +# ----------------------------------------------------------------------------- +# Lua headers: reused from the engine's FetchContent cache (the same Lua +# VM Sporel.exe boots into; no separate Lua build for the lib). At runtime, +# Lua C-API symbols (lua_*, luaL_*) are resolved against the re-exports +# in sporel_engine.dll (per ADR-0023). +# +# At link time on MinGW we need an import library that resolves those +# symbols; we use libsporel_engine.dll.a from the engine's build tree. +# Caller MUST set both -DLUA_INCLUDE_DIR_HINT and -DSPOREL_ENGINE_BUILD_HINT. +# ----------------------------------------------------------------------------- +if(NOT DEFINED LUA_INCLUDE_DIR_HINT) + set(LUA_INCLUDE_DIR_HINT + "$ENV{HOME}/Projects/Sporel/sporel-engine/build/_deps/lua-src/src") +endif() +if(NOT DEFINED SPOREL_ENGINE_BUILD_HINT) + set(SPOREL_ENGINE_BUILD_HINT + "$ENV{HOME}/Projects/Sporel/sporel-engine/build") +endif() +if(NOT DEFINED SPOREL_ENGINE_INCLUDE_HINT) + set(SPOREL_ENGINE_INCLUDE_HINT + "$ENV{HOME}/Projects/Sporel/sporel-engine/include") +endif() +if(NOT DEFINED SPOREL_ENGINE_SRC_HINT) + # engine_async.h lives in src/ (private engine headers). Native libs + # that use the async-pool need this for the sporel_async_handle and + # sporel_async_work_fn typedefs — though engine_api.h re-declares them. + set(SPOREL_ENGINE_SRC_HINT + "$ENV{HOME}/Projects/Sporel/sporel-engine/src") +endif() + +find_path(LUA_INCLUDE_DIR lua.h HINTS ${LUA_INCLUDE_DIR_HINT}) +if(NOT LUA_INCLUDE_DIR) + message(FATAL_ERROR + "sporel_git: could not find lua.h. Pass " + "-DLUA_INCLUDE_DIR_HINT=/path/to/sporel-engine/build/_deps/lua-src/src " + "(default = ~/Projects/Sporel/sporel-engine/build/_deps/lua-src/src).") +endif() + +find_library(SPOREL_ENGINE_IMPLIB + NAMES sporel_engine libsporel_engine + HINTS ${SPOREL_ENGINE_BUILD_HINT} + NO_DEFAULT_PATH) +if(NOT SPOREL_ENGINE_IMPLIB) + message(FATAL_ERROR + "sporel_git: could not find libsporel_engine.dll.a in " + "${SPOREL_ENGINE_BUILD_HINT}. Build the engine first.") +endif() + +# ----------------------------------------------------------------------------- +# sporel_git shared module +# ----------------------------------------------------------------------------- +add_library(sporel_git MODULE src/sporel_git.c) + +target_include_directories(sporel_git PRIVATE + src + ${LUA_INCLUDE_DIR} + ${SPOREL_ENGINE_INCLUDE_HINT} # for sporel/engine_api.h + ${libgit2_SOURCE_DIR}/include) + +# libgit2's main target is named "libgit2package" (the on-disk artifact +# is libgit2.a / git2.dll; CMake target name lives independently per +# libgit2's CMakeLists.txt at src/libgit2/CMakeLists.txt:59). +# Linking against the CMake target propagates transitive system libs +# (WinHTTP, ws2_32, crypt32, etc.) automatically. +target_link_libraries(sporel_git PRIVATE + libgit2package + ${SPOREL_ENGINE_IMPLIB}) + +# When linking into sporel_engine.dll at runtime the Lua symbols come from +# its re-exports, so we treat the engine import lib as the Lua import lib +# too (no separate lua54 target). This avoids needing two Lua copies. + +set_target_properties(sporel_git PROPERTIES + PREFIX "" + OUTPUT_NAME "sporel_git") + +# Silence libgit2's compile warnings — they leak through FetchContent. +if(MSVC) + target_compile_options(sporel_git PRIVATE /W2) +else() + target_compile_options(sporel_git PRIVATE -Wall -Wno-unused-parameter) +endif() + +# On Linux: --unresolved-symbols=ignore-in-shared-libs would let us drop +# the engine import lib, but the import-lib path is uniform across both +# platforms — keep it. diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..244bdda --- /dev/null +++ b/manifest.lib @@ -0,0 +1,7 @@ +{ + "id": "lib-core.git", + "version": "0.1.0", + "api_min": "0.1", + "native": true, + "deps": [] +} diff --git a/sporel_git.dll b/sporel_git.dll new file mode 100644 index 0000000..73cba42 Binary files /dev/null and b/sporel_git.dll differ diff --git a/src/sporel_git.c b/src/sporel_git.c new file mode 100644 index 0000000..d44b6a6 --- /dev/null +++ b/src/sporel_git.c @@ -0,0 +1,377 @@ +/* sporel_git.c — lib-core.git v0.1.0 + * + * Native libgit2 wrapper exposed to Lua. Synchronous + asynchronous API. + * All async operations dispatch through the engine's worker-thread pool + * via the sporel_engine_api struct passed to sporel_lib_init (Slice 4 + * ADR-0046). + * + * Transport: HTTPS only (WinHTTP on Windows, OpenSSL/mbedTLS on Linux + * depending on libgit2's autodetection). No SSH support — keeps the + * static link surface small and avoids libssh2 + dependent crypto. + * + * Threading model: libgit2 is fully thread-safe AFTER git_libgit2_init. + * We init once on sporel_lib_init. The async worker that runs ls-remote + * is the only place we touch libgit2 from a non-Lua thread. + */ + +#include "lua.h" +#include "lauxlib.h" +#include "git2.h" +#include "sporel/engine_api.h" + +#include +#include +#include + +#ifdef _WIN32 +# define SPOREL_EXPORT __declspec(dllexport) +#else +# define SPOREL_EXPORT __attribute__((visibility("default"))) +#endif + +/* Pointer to the engine-provided API struct. Populated by + * sporel_lib_init; used by the async wrappers below. */ +static const sporel_engine_api *g_api = NULL; + +/* Call once per process; thread-safe via libgit2's own internal flag. */ +static void ensure_libgit2_init(void) { + static int initialized = 0; + if (!initialized) { + git_libgit2_init(); + initialized = 1; + } +} + +/* ---- Synchronous bindings ------------------------------------------------ */ + +/* git.clone(url, dest_dir, ref) -> ok, err + * ref is optional — when set, libgit2 checks out that branch after clone. */ +static int l_clone(lua_State *L) { + const char *url = luaL_checkstring(L, 1); + const char *dest = luaL_checkstring(L, 2); + const char *ref = luaL_optstring(L, 3, NULL); + + git_repository *repo = NULL; + git_clone_options opts = GIT_CLONE_OPTIONS_INIT; + if (ref) opts.checkout_branch = ref; + + int rc = git_clone(&repo, url, dest, &opts); + if (rc != 0) { + lua_pushboolean(L, 0); + const git_error *e = git_error_last(); + lua_pushstring(L, e ? e->message : "git_clone failed"); + return 2; + } + git_repository_free(repo); + lua_pushboolean(L, 1); + lua_pushnil(L); + return 2; +} + +/* git.checkout(dir, ref) -> ok, err + * Resolves the ref (tag, branch or sha) and detaches HEAD onto its commit. */ +static int l_checkout(lua_State *L) { + const char *dir = luaL_checkstring(L, 1); + const char *ref = luaL_checkstring(L, 2); + + git_repository *repo = NULL; + git_object *target = NULL; + + int rc = git_repository_open(&repo, dir); + if (rc != 0) goto err; + + rc = git_revparse_single(&target, repo, ref); + if (rc != 0) goto err; + + git_checkout_options copts = GIT_CHECKOUT_OPTIONS_INIT; + copts.checkout_strategy = GIT_CHECKOUT_SAFE; + rc = git_checkout_tree(repo, target, &copts); + if (rc != 0) goto err; + + rc = git_repository_set_head_detached(repo, git_object_id(target)); + if (rc != 0) goto err; + + git_object_free(target); + git_repository_free(repo); + lua_pushboolean(L, 1); + lua_pushnil(L); + return 2; + +err:; + const git_error *e = git_error_last(); + lua_pushboolean(L, 0); + lua_pushstring(L, e ? e->message : "checkout failed"); + if (target) git_object_free(target); + if (repo) git_repository_free(repo); + return 2; +} + +/* git.describe(dir) -> desc_str or nil, err */ +static int l_describe(lua_State *L) { + const char *dir = luaL_checkstring(L, 1); + + git_repository *repo = NULL; + git_describe_result *result = NULL; + git_buf out = { 0 }; + + int rc = git_repository_open(&repo, dir); + if (rc != 0) goto err; + + git_describe_options dopts = GIT_DESCRIBE_OPTIONS_INIT; + dopts.describe_strategy = GIT_DESCRIBE_TAGS; + rc = git_describe_workdir(&result, repo, &dopts); + if (rc != 0) goto err; + + git_describe_format_options fopts = GIT_DESCRIBE_FORMAT_OPTIONS_INIT; + fopts.dirty_suffix = "-dirty"; + rc = git_describe_format(&out, result, &fopts); + if (rc != 0) goto err; + + lua_pushstring(L, out.ptr); + lua_pushnil(L); + git_buf_dispose(&out); + git_describe_result_free(result); + git_repository_free(repo); + return 2; + +err:; + const git_error *e = git_error_last(); + lua_pushnil(L); + lua_pushstring(L, e ? e->message : "describe failed"); + if (result) git_describe_result_free(result); + if (repo) git_repository_free(repo); + return 2; +} + +/* git.has_ref(dir, ref) -> bool */ +static int l_has_ref(lua_State *L) { + const char *dir = luaL_checkstring(L, 1); + const char *ref = luaL_checkstring(L, 2); + git_repository *repo = NULL; + if (git_repository_open(&repo, dir) != 0) { + lua_pushboolean(L, 0); + return 1; + } + git_object *o = NULL; + int rc = git_revparse_single(&o, repo, ref); + lua_pushboolean(L, rc == 0); + if (o) git_object_free(o); + git_repository_free(repo); + return 1; +} + +/* git.is_repo(dir) -> bool */ +static int l_is_repo(lua_State *L) { + const char *dir = luaL_checkstring(L, 1); + git_repository *repo = NULL; + int rc = git_repository_open(&repo, dir); + if (rc == 0) { + git_repository_free(repo); + lua_pushboolean(L, 1); + } else { + lua_pushboolean(L, 0); + } + return 1; +} + +/* git.tags(dir) -> {tag1, tag2, ...}, err */ +static int l_tags(lua_State *L) { + const char *dir = luaL_checkstring(L, 1); + git_repository *repo = NULL; + if (git_repository_open(&repo, dir) != 0) { + lua_pushnil(L); + lua_pushstring(L, "not a repo"); + return 2; + } + git_strarray names = { 0 }; + int rc = git_tag_list(&names, repo); + if (rc != 0) { + const git_error *e = git_error_last(); + lua_pushnil(L); + lua_pushstring(L, e ? e->message : "git_tag_list failed"); + git_repository_free(repo); + return 2; + } + lua_createtable(L, (int)names.count, 0); + for (size_t i = 0; i < names.count; i++) { + lua_pushstring(L, names.strings[i]); + lua_rawseti(L, -2, (int)(i + 1)); + } + git_strarray_dispose(&names); + git_repository_free(repo); + lua_pushnil(L); + return 2; +} + +/* git.fetch(url, dir) -> ok, err + * Anonymous-remote fetch — does not mutate origin's refspecs. */ +static int l_fetch(lua_State *L) { + const char *url = luaL_checkstring(L, 1); + const char *dir = luaL_checkstring(L, 2); + git_repository *repo = NULL; + git_remote *remote = NULL; + + if (git_repository_open(&repo, dir) != 0) goto err; + if (git_remote_create_anonymous(&remote, repo, url) != 0) goto err; + + git_fetch_options fopts = GIT_FETCH_OPTIONS_INIT; + int rc = git_remote_fetch(remote, NULL, &fopts, NULL); + git_remote_free(remote); + git_repository_free(repo); + if (rc != 0) { + const git_error *e = git_error_last(); + lua_pushboolean(L, 0); + lua_pushstring(L, e ? e->message : "fetch failed"); + return 2; + } + lua_pushboolean(L, 1); + lua_pushnil(L); + return 2; + +err:; + const git_error *e = git_error_last(); + lua_pushboolean(L, 0); + lua_pushstring(L, e ? e->message : "fetch open failed"); + if (remote) git_remote_free(remote); + if (repo) git_repository_free(repo); + return 2; +} + +/* ---- Asynchronous bindings ----------------------------------------------- */ + +#define GIT_ASYNC_MAX_TAGS 256 +#define GIT_ASYNC_TAG_LEN 128 + +typedef struct { + char url[1024]; + /* outputs: */ + int ok; + char err[512]; + int tag_count; + char tags[GIT_ASYNC_MAX_TAGS][GIT_ASYNC_TAG_LEN]; +} git_async_ctx; + +/* Worker body — runs on an engine_async pool thread. Must NOT touch the + * Lua state (single-threaded). All result-data is written into ctx. */ +static void do_ls_remote_tags(void *vctx) { + git_async_ctx *c = (git_async_ctx *)vctx; + git_remote *remote = NULL; + + int rc = git_remote_create_detached(&remote, c->url); + if (rc != 0) goto fail; + + git_remote_callbacks cbs = GIT_REMOTE_CALLBACKS_INIT; + rc = git_remote_connect(remote, GIT_DIRECTION_FETCH, &cbs, NULL, NULL); + if (rc != 0) goto fail; + + const git_remote_head **heads = NULL; + size_t n = 0; + rc = git_remote_ls(&heads, &n, remote); + if (rc != 0) goto fail; + + c->tag_count = 0; + for (size_t i = 0; i < n && c->tag_count < GIT_ASYNC_MAX_TAGS; i++) { + if (strncmp(heads[i]->name, "refs/tags/", 10) == 0) { + /* Skip the peeled-tag duplicates (^{}) that ls-remote can emit. */ + const char *name = heads[i]->name + 10; + size_t nlen = strlen(name); + if (nlen >= 3 && strcmp(name + nlen - 3, "^{}") == 0) continue; + snprintf(c->tags[c->tag_count], GIT_ASYNC_TAG_LEN, "%s", name); + c->tag_count++; + } + } + c->ok = 1; + git_remote_disconnect(remote); + git_remote_free(remote); + return; + +fail:; + const git_error *e = git_error_last(); + snprintf(c->err, sizeof c->err, "%s", + e && e->message ? e->message : "ls_remote failed"); + c->ok = 0; + if (remote) git_remote_free(remote); +} + +/* Lua-owned handle for an in-flight async op. We allocate it via + * lua_newuserdata so the GC owns its memory; the engine-pool handle is + * released in take_result. */ +typedef struct { + sporel_async_handle *h; /* engine-owned; freed by take_result */ + git_async_ctx ctx; /* Lua-owned via userdata */ +} git_handle_userdata; + +/* git.start_ls_remote_tags(url) -> handle (userdata) */ +static int l_start_ls_remote_tags(lua_State *L) { + if (!g_api) { + return luaL_error(L, + "sporel_git: engine_api not wired (sporel_lib_init bug)"); + } + const char *url = luaL_checkstring(L, 1); + git_handle_userdata *u = + (git_handle_userdata *)lua_newuserdata(L, sizeof(*u)); + memset(u, 0, sizeof(*u)); + snprintf(u->ctx.url, sizeof u->ctx.url, "%s", url); + u->h = g_api->async_submit(do_ls_remote_tags, &u->ctx, NULL, 0); + return 1; +} + +/* git.is_done(handle) -> bool */ +static int l_is_done(lua_State *L) { + git_handle_userdata *u = (git_handle_userdata *)lua_touserdata(L, 1); + if (!u) return luaL_error(L, "git.is_done: expected userdata"); + if (!g_api) return luaL_error(L, "sporel_git: engine_api not wired"); + lua_pushboolean(L, g_api->async_is_done(u->h)); + return 1; +} + +/* git.take_result(handle) -> {tags = {...}} | {error = "..."} | nil, err */ +static int l_take_result(lua_State *L) { + git_handle_userdata *u = + (git_handle_userdata *)lua_touserdata(L, 1); + if (!u) return luaL_error(L, "git.take_result: expected userdata"); + if (!g_api) return luaL_error(L, "sporel_git: engine_api not wired"); + if (!g_api->async_take_result(u->h)) { + lua_pushnil(L); + lua_pushstring(L, "not done"); + return 2; + } + u->h = NULL; /* engine freed the handle */ + + lua_newtable(L); + if (u->ctx.ok) { + lua_createtable(L, u->ctx.tag_count, 0); + for (int i = 0; i < u->ctx.tag_count; i++) { + lua_pushstring(L, u->ctx.tags[i]); + lua_rawseti(L, -2, i + 1); + } + lua_setfield(L, -2, "tags"); + } else { + lua_pushstring(L, u->ctx.err); + lua_setfield(L, -2, "error"); + } + return 1; +} + +/* ---- sporel_lib_init ----------------------------------------------------- */ + +SPOREL_EXPORT int sporel_lib_init(lua_State *L, const sporel_engine_api *api) { + g_api = api; + ensure_libgit2_init(); + + lua_newtable(L); + + lua_pushcfunction(L, l_clone); lua_setfield(L, -2, "clone"); + lua_pushcfunction(L, l_checkout); lua_setfield(L, -2, "checkout"); + lua_pushcfunction(L, l_describe); lua_setfield(L, -2, "describe"); + lua_pushcfunction(L, l_has_ref); lua_setfield(L, -2, "has_ref"); + lua_pushcfunction(L, l_is_repo); lua_setfield(L, -2, "is_repo"); + lua_pushcfunction(L, l_tags); lua_setfield(L, -2, "tags"); + lua_pushcfunction(L, l_fetch); lua_setfield(L, -2, "fetch"); + + lua_pushcfunction(L, l_start_ls_remote_tags); lua_setfield(L, -2, "start_ls_remote_tags"); + lua_pushcfunction(L, l_is_done); lua_setfield(L, -2, "is_done"); + lua_pushcfunction(L, l_take_result); lua_setfield(L, -2, "take_result"); + + return 1; +}