/* 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; /* Optional Gitea / HTTP-Basic token. Populated at sporel_lib_init from * SPOREL_GITEA_TOKEN. When set, every libgit2 transport request that * asks for credentials gets username="token", password=g_token via the * cred-acquire callback. Empty string = anonymous (callback declines). * * Read once at init; no per-call override yet. A future engine.config * binding can refresh it (rebind via a setter) when settings UI lands. */ static char g_token[512] = ""; /* 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; } } /* HTTP-Basic credential callback. libgit2 invokes this when the remote * demands auth. We service USERPASS_PLAINTEXT (Gitea token via * Basic-Auth, username literally "token") and decline everything else * (SSH-key types are unsupported since we ship no SSH transport). */ static int sporel_cred_acquire_cb(git_credential **out, const char *url, const char *username_from_url, unsigned int allowed_types, void *payload) { (void)url; (void)username_from_url; (void)payload; if (!(allowed_types & GIT_CREDENTIAL_USERPASS_PLAINTEXT)) { return GIT_PASSTHROUGH; } if (g_token[0] == '\0') { /* No token configured — fall through so libgit2 surfaces the * raw "authentication required" error to the caller. */ return GIT_PASSTHROUGH; } return git_credential_userpass_plaintext_new(out, "token", g_token); } /* Map a libgit2 error to a stable "kind" string the Lua side surfaces * in result.kind. Lets the launcher distinguish local-only repos * (404 with creds) from need-auth (401 without creds) from genuine * network failures. Pattern-matches the error message because * libgit2's error-class taxonomy varies across HTTP backends. */ static const char *classify_git_error(int rc, const git_error *e) { const char *msg = (e && e->message) ? e->message : ""; if (rc == GIT_EAUTH) return "auth-required"; if (strstr(msg, "401") || strstr(msg, "authentication required") || strstr(msg, "Authentication")) return "auth-required"; if (strstr(msg, "404") || strstr(msg, "Not Found") || strstr(msg, "not found")) return "not-found"; if (e && (e->klass == GIT_ERROR_NET || e->klass == GIT_ERROR_HTTP || e->klass == GIT_ERROR_SSL)) return "network"; return "other"; } /* ---- 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; opts.fetch_opts.callbacks.credentials = sporel_cred_acquire_cb; 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; fopts.callbacks.credentials = sporel_cred_acquire_cb; 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]; char kind[32]; /* "auth-required" | "not-found" | "network" | "other" */ 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; cbs.credentials = sporel_cred_acquire_cb; 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"); snprintf(c->kind, sizeof c->kind, "%s", classify_git_error(rc, e)); 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.set_token(token | nil) -> nil * Replaces the in-memory Gitea HTTP-Basic token used by the credential * callback. Passing nil / empty string reverts to anonymous. Source of * truth at lib-init is SPOREL_GITEA_TOKEN env; the launcher (or any * consumer) calls this to override with engine.config("gitea_token") * once the Lua engine bindings are live. */ static int l_set_token(lua_State *L) { if (lua_isnoneornil(L, 1)) { g_token[0] = '\0'; return 0; } const char *t = luaL_checkstring(L, 1); snprintf(g_token, sizeof g_token, "%s", t); return 0; } /* 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"); lua_pushstring(L, u->ctx.kind); lua_setfield(L, -2, "kind"); } 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(); /* Latch the Gitea token from env, if present. Empty/unset → anon. */ const char *tok = getenv("SPOREL_GITEA_TOKEN"); if (tok && *tok) { snprintf(g_token, sizeof g_token, "%s", tok); } 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"); lua_pushcfunction(L, l_set_token); lua_setfield(L, -2, "set_token"); return 1; }