From f02511d2f87a7adbb7172fe9011074c0b114bdce Mon Sep 17 00:00:00 2001 From: Sosuke Suzuki Date: Wed, 4 Feb 2026 11:50:25 +0900 Subject: [PATCH] feat: add `bun:git` module with libgit2 integration Adds a new built-in module for Git operations using libgit2 (v1.9.0) statically linked. Initial implementation includes Repository.open(), head(), and Commit properties (id, message, summary, author, committer, time). Designed for local-only operations with lazy initialization to avoid overhead for users who don't need Git functionality. Co-Authored-By: Claude Opus 4.5 --- cmake/Sources.json | 1 + cmake/targets/BuildBun.cmake | 3 +- cmake/targets/BuildLibgit2.cmake | 40 ++ src/bun.js/HardcodedModule.zig | 3 + src/bun.js/bindings/ZigGlobalObject.cpp | 11 + src/bun.js/bindings/ZigGlobalObject.h | 5 + src/bun.js/bindings/git/JSGit.cpp | 570 ++++++++++++++++++ src/bun.js/bindings/git/JSGit.h | 140 +++++ .../bindings/webcore/DOMClientIsoSubspaces.h | 2 + src/bun.js/bindings/webcore/DOMIsoSubspaces.h | 2 + src/js/bun/git.ts | 128 ++++ test/js/bun/git/repository.test.ts | 109 ++++ 12 files changed, 1013 insertions(+), 1 deletion(-) create mode 100644 cmake/targets/BuildLibgit2.cmake create mode 100644 src/bun.js/bindings/git/JSGit.cpp create mode 100644 src/bun.js/bindings/git/JSGit.h create mode 100644 src/js/bun/git.ts create mode 100644 test/js/bun/git/repository.test.ts diff --git a/cmake/Sources.json b/cmake/Sources.json index 2f5339f047..df72ffd2a3 100644 --- a/cmake/Sources.json +++ b/cmake/Sources.json @@ -46,6 +46,7 @@ "src/io/*.cpp", "src/bun.js/modules/*.cpp", "src/bun.js/bindings/*.cpp", + "src/bun.js/bindings/git/*.cpp", "src/bun.js/bindings/webcore/*.cpp", "src/bun.js/bindings/sqlite/*.cpp", "src/bun.js/bindings/webcrypto/*.cpp", diff --git a/cmake/targets/BuildBun.cmake b/cmake/targets/BuildBun.cmake index d692ea4387..f85b006250 100644 --- a/cmake/targets/BuildBun.cmake +++ b/cmake/targets/BuildBun.cmake @@ -54,6 +54,7 @@ set(BUN_DEPENDENCIES Cares Highway LibDeflate + Libgit2 LolHtml Lshpack Mimalloc @@ -1322,7 +1323,7 @@ list(TRANSFORM BUN_DEPENDENCIES TOLOWER OUTPUT_VARIABLE BUN_TARGETS) add_custom_target(dependencies DEPENDS ${BUN_TARGETS}) if(APPLE) - target_link_libraries(${bun} PRIVATE icucore resolv) + target_link_libraries(${bun} PRIVATE icucore resolv iconv) target_compile_definitions(${bun} PRIVATE U_DISABLE_RENAMING=1) endif() diff --git a/cmake/targets/BuildLibgit2.cmake b/cmake/targets/BuildLibgit2.cmake new file mode 100644 index 0000000000..d62f6a646b --- /dev/null +++ b/cmake/targets/BuildLibgit2.cmake @@ -0,0 +1,40 @@ +register_repository( + NAME + libgit2 + REPOSITORY + libgit2/libgit2 + TAG + v1.9.0 +) + +register_cmake_command( + TARGET + libgit2 + TARGETS + libgit2package + ARGS + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + -DBUILD_SHARED_LIBS=OFF + -DBUILD_TESTS=OFF + -DBUILD_CLI=OFF + -DBUILD_EXAMPLES=OFF + -DBUILD_FUZZERS=OFF + # Network disabled - local operations only + -DUSE_HTTPS=OFF + -DUSE_SSH=OFF + # Use bundled dependencies to avoid symbol conflicts with Bun's libraries + -DUSE_BUNDLED_ZLIB=ON + -DUSE_HTTP_PARSER=builtin + -DREGEX_BACKEND=builtin + -DUSE_SHA1=CollisionDetection + # Enable threading + -DUSE_THREADS=ON + # Disable authentication features (not needed for local operations) + -DUSE_GSSAPI=OFF + LIB_PATH + . + LIBRARIES + git2 + INCLUDES + include +) diff --git a/src/bun.js/HardcodedModule.zig b/src/bun.js/HardcodedModule.zig index 84f6ff9968..1593964842 100644 --- a/src/bun.js/HardcodedModule.zig +++ b/src/bun.js/HardcodedModule.zig @@ -10,6 +10,7 @@ pub const HardcodedModule = enum { @"bun:test", @"bun:wrap", @"bun:sqlite", + @"bun:git", @"node:assert", @"node:assert/strict", @"node:async_hooks", @@ -98,6 +99,7 @@ pub const HardcodedModule = enum { .{ "bun:main", .@"bun:main" }, .{ "bun:test", .@"bun:test" }, .{ "bun:sqlite", .@"bun:sqlite" }, + .{ "bun:git", .@"bun:git" }, .{ "bun:wrap", .@"bun:wrap" }, .{ "bun:internal-for-testing", .@"bun:internal-for-testing" }, // Node.js @@ -366,6 +368,7 @@ pub const HardcodedModule = enum { .{ "bun:ffi", .{ .path = "bun:ffi" } }, .{ "bun:jsc", .{ .path = "bun:jsc" } }, .{ "bun:sqlite", .{ .path = "bun:sqlite" } }, + .{ "bun:git", .{ .path = "bun:git" } }, .{ "bun:wrap", .{ .path = "bun:wrap" } }, .{ "bun:internal-for-testing", .{ .path = "bun:internal-for-testing" } }, .{ "ffi", .{ .path = "bun:ffi" } }, diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 31373b7359..ed7de37e53 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -125,6 +125,7 @@ #include "JSSocketAddressDTO.h" #include "JSReactElement.h" #include "JSSQLStatement.h" +#include "git/JSGit.h" #include "JSStringDecoder.h" #include "JSTextEncoder.h" #include "JSTextEncoderStream.h" @@ -1868,6 +1869,16 @@ void GlobalObject::finishCreation(VM& vm) init.set(WebCore::createJSSQLStatementStructure(init.owner)); }); + m_JSGitRepositoryStructure.initLater( + [](const Initializer& init) { + init.set(WebCore::createJSGitRepositoryStructure(init.owner)); + }); + + m_JSGitCommitStructure.initLater( + [](const Initializer& init) { + init.set(WebCore::createJSGitCommitStructure(init.owner)); + }); + m_V8GlobalInternals.initLater( [](const JSC::LazyProperty::Initializer& init) { init.set( diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index 21c8d41f5c..3e70dcb61d 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -316,6 +316,9 @@ public: Structure* JSSQLStatementStructure() const { return m_JSSQLStatementStructure.getInitializedOnMainThread(this); } + Structure* JSGitRepositoryStructure() const { return m_JSGitRepositoryStructure.getInitializedOnMainThread(this); } + Structure* JSGitCommitStructure() const { return m_JSGitCommitStructure.getInitializedOnMainThread(this); } + v8::shim::GlobalInternals* V8GlobalInternals() const { return m_V8GlobalInternals.getInitializedOnMainThread(this); } Bun::BakeAdditionsToGlobalObject& bakeAdditions() { return m_bakeAdditions; } @@ -620,6 +623,8 @@ public: V(private, LazyPropertyOfGlobalObject, m_NapiTypeTagStructure) \ \ V(private, LazyPropertyOfGlobalObject, m_JSSQLStatementStructure) \ + V(private, LazyPropertyOfGlobalObject, m_JSGitRepositoryStructure) \ + V(private, LazyPropertyOfGlobalObject, m_JSGitCommitStructure) \ V(private, LazyPropertyOfGlobalObject, m_V8GlobalInternals) \ \ V(public, LazyPropertyOfGlobalObject, m_bunObject) \ diff --git a/src/bun.js/bindings/git/JSGit.cpp b/src/bun.js/bindings/git/JSGit.cpp new file mode 100644 index 0000000000..39a82cf0a8 --- /dev/null +++ b/src/bun.js/bindings/git/JSGit.cpp @@ -0,0 +1,570 @@ +/* + * Copyright (C) 2024 Oven-sh + */ + +#include "root.h" + +#include "JavaScriptCore/Error.h" +#include "JavaScriptCore/Structure.h" +#include "JavaScriptCore/ThrowScope.h" +#include "JavaScriptCore/ExceptionScope.h" +#include "JavaScriptCore/JSType.h" + +#include "JSGit.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "BunBuiltinNames.h" +#include "BunString.h" + +#include +#include + +namespace WebCore { + +// Lazy initialization of libgit2 +static std::once_flag s_libgit2InitFlag; + +static void ensureLibgit2Initialized() +{ + std::call_once(s_libgit2InitFlag, []() { + git_libgit2_init(); + }); +} + +// Helper to create error from libgit2 error +static JSC::JSValue createGitError(JSC::JSGlobalObject* globalObject, const char* message) +{ + const git_error* err = git_error_last(); + WTF::String errorMessage; + if (err && err->message) { + errorMessage = WTF::String::fromUTF8(err->message); + } else if (message) { + errorMessage = WTF::String::fromUTF8(message); + } else { + errorMessage = "Unknown git error"_s; + } + return JSC::createError(globalObject, errorMessage); +} + +// ============================================================================ +// JSGitRepository Implementation +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryOpen, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + ensureLibgit2Initialized(); + + if (callFrame->argumentCount() < 1) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Repository.open requires a path argument"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::JSValue pathValue = callFrame->argument(0); + if (!pathValue.isString()) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Path must be a string"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + WTF::String pathString = pathValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + + WTF::CString pathCString = pathString.utf8(); + + git_repository* repo = nullptr; + int error = git_repository_open(&repo, pathCString.data()); + + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to open repository")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + auto* globalObject = JSC::jsDynamicCast(lexicalGlobalObject); + if (!globalObject) { + git_repository_free(repo); + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid global object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::Structure* structure = globalObject->JSGitRepositoryStructure(); + JSGitRepository* jsRepo = JSGitRepository::create(vm, structure, repo); + + return JSC::JSValue::encode(jsRepo); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitRepositoryGetPath, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitRepository* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Repository object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_repository* repo = thisObject->repository(); + if (!repo) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Repository has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const char* path = git_repository_path(repo); + if (!path) { + return JSC::JSValue::encode(JSC::jsNull()); + } + + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(path))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitRepositoryGetWorkdir, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitRepository* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Repository object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_repository* repo = thisObject->repository(); + if (!repo) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Repository has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const char* workdir = git_repository_workdir(repo); + if (!workdir) { + return JSC::JSValue::encode(JSC::jsNull()); + } + + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(workdir))); +} + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryHead, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitRepository* thisObject = JSC::jsDynamicCast(callFrame->thisValue()); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Repository object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_repository* repo = thisObject->repository(); + if (!repo) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Repository has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_reference* headRef = nullptr; + int error = git_repository_head(&headRef, repo); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to get HEAD")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const git_oid* oid = git_reference_target(headRef); + if (!oid) { + git_reference_free(headRef); + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "HEAD is not a direct reference"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = nullptr; + error = git_commit_lookup(&commit, repo, oid); + git_reference_free(headRef); + + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to lookup HEAD commit")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + auto* globalObject = JSC::jsDynamicCast(lexicalGlobalObject); + if (!globalObject) { + git_commit_free(commit); + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid global object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::Structure* structure = globalObject->JSGitCommitStructure(); + JSGitCommit* jsCommit = JSGitCommit::create(vm, structure, commit); + + return JSC::JSValue::encode(jsCommit); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitRepositoryIsBare, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitRepository* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Repository object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_repository* repo = thisObject->repository(); + if (!repo) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Repository has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + return JSC::JSValue::encode(JSC::jsBoolean(git_repository_is_bare(repo))); +} + +static const HashTableValue JSGitRepositoryPrototypeTableValues[] = { + { "head"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryHead, 0 } }, + { "path"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryGetPath, 0 } }, + { "workdir"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryGetWorkdir, 0 } }, + { "isBare"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryIsBare, 0 } }, +}; + +class JSGitRepositoryPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static JSGitRepositoryPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + JSGitRepositoryPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSGitRepositoryPrototype(vm, globalObject, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSGitRepositoryPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSGitRepositoryPrototype(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + { + Base::finishCreation(vm); + reifyStaticProperties(vm, JSGitRepositoryPrototype::info(), JSGitRepositoryPrototypeTableValues, *this); + } +}; + +const ClassInfo JSGitRepositoryPrototype::s_info = { "Repository"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSGitRepositoryPrototype) }; +const ClassInfo JSGitRepository::s_info = { "Repository"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSGitRepository) }; + +JSGitRepository* JSGitRepository::create(JSC::VM& vm, JSC::Structure* structure, git_repository* repo) +{ + JSGitRepository* ptr = new (NotNull, JSC::allocateCell(vm)) JSGitRepository(vm, structure, repo); + ptr->finishCreation(vm); + return ptr; +} + +void JSGitRepository::finishCreation(JSC::VM& vm) +{ + Base::finishCreation(vm); +} + +void JSGitRepository::destroy(JSC::JSCell* cell) +{ + JSGitRepository* thisObject = static_cast(cell); + if (thisObject->m_repo) { + git_repository_free(thisObject->m_repo); + thisObject->m_repo = nullptr; + } + thisObject->~JSGitRepository(); +} + +JSC::Structure* createJSGitRepositoryStructure(JSC::JSGlobalObject* globalObject) +{ + JSC::Structure* prototypeStructure = JSGitRepositoryPrototype::createStructure(globalObject->vm(), globalObject, globalObject->objectPrototype()); + prototypeStructure->setMayBePrototype(true); + JSGitRepositoryPrototype* prototype = JSGitRepositoryPrototype::create(globalObject->vm(), globalObject, prototypeStructure); + return JSGitRepository::createStructure(globalObject->vm(), globalObject, prototype); +} + +// ============================================================================ +// JSGitCommit Implementation +// ============================================================================ + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetId, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const git_oid* oid = git_commit_id(commit); + char oidStr[GIT_OID_SHA1_HEXSIZE + 1]; + git_oid_tostr(oidStr, sizeof(oidStr), oid); + + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(oidStr))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetMessage, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const char* message = git_commit_message(commit); + if (!message) { + return JSC::JSValue::encode(JSC::jsEmptyString(vm)); + } + + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(message))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetSummary, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const char* summary = git_commit_summary(commit); + if (!summary) { + return JSC::JSValue::encode(JSC::jsEmptyString(vm)); + } + + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(summary))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetAuthor, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const git_signature* author = git_commit_author(commit); + if (!author) { + return JSC::JSValue::encode(JSC::jsNull()); + } + + JSC::JSObject* obj = JSC::constructEmptyObject(lexicalGlobalObject); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "name"_s), + JSC::jsString(vm, WTF::String::fromUTF8(author->name ? author->name : ""))); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "email"_s), + JSC::jsString(vm, WTF::String::fromUTF8(author->email ? author->email : ""))); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "time"_s), + JSC::jsNumber(static_cast(author->when.time) * 1000.0)); // Convert to milliseconds for JS Date + + return JSC::JSValue::encode(obj); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetCommitter, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const git_signature* committer = git_commit_committer(commit); + if (!committer) { + return JSC::JSValue::encode(JSC::jsNull()); + } + + JSC::JSObject* obj = JSC::constructEmptyObject(lexicalGlobalObject); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "name"_s), + JSC::jsString(vm, WTF::String::fromUTF8(committer->name ? committer->name : ""))); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "email"_s), + JSC::jsString(vm, WTF::String::fromUTF8(committer->email ? committer->email : ""))); + obj->putDirect(vm, JSC::Identifier::fromString(vm, "time"_s), + JSC::jsNumber(static_cast(committer->when.time) * 1000.0)); + + return JSC::JSValue::encode(obj); +} + +JSC_DEFINE_CUSTOM_GETTER(jsGitCommitGetTime, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSGitCommit* thisObject = JSC::jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (!thisObject) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Expected Commit object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_commit* commit = thisObject->commit(); + if (!commit) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Commit has been freed"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_time_t time = git_commit_time(commit); + return JSC::JSValue::encode(JSC::jsNumber(static_cast(time))); +} + +static const HashTableValue JSGitCommitPrototypeTableValues[] = { + { "id"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetId, 0 } }, + { "message"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetMessage, 0 } }, + { "summary"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetSummary, 0 } }, + { "author"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetAuthor, 0 } }, + { "committer"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetCommitter, 0 } }, + { "time"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitCommitGetTime, 0 } }, +}; + +class JSGitCommitPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static JSGitCommitPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + JSGitCommitPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSGitCommitPrototype(vm, globalObject, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSGitCommitPrototype, Base); + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSGitCommitPrototype(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + { + Base::finishCreation(vm); + reifyStaticProperties(vm, JSGitCommitPrototype::info(), JSGitCommitPrototypeTableValues, *this); + } +}; + +const ClassInfo JSGitCommitPrototype::s_info = { "Commit"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSGitCommitPrototype) }; +const ClassInfo JSGitCommit::s_info = { "Commit"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSGitCommit) }; + +JSGitCommit* JSGitCommit::create(JSC::VM& vm, JSC::Structure* structure, git_commit* commit) +{ + JSGitCommit* ptr = new (NotNull, JSC::allocateCell(vm)) JSGitCommit(vm, structure, commit); + ptr->finishCreation(vm); + return ptr; +} + +void JSGitCommit::finishCreation(JSC::VM& vm) +{ + Base::finishCreation(vm); +} + +void JSGitCommit::destroy(JSC::JSCell* cell) +{ + JSGitCommit* thisObject = static_cast(cell); + if (thisObject->m_commit) { + git_commit_free(thisObject->m_commit); + thisObject->m_commit = nullptr; + } + thisObject->~JSGitCommit(); +} + +JSC::Structure* createJSGitCommitStructure(JSC::JSGlobalObject* globalObject) +{ + JSC::Structure* prototypeStructure = JSGitCommitPrototype::createStructure(globalObject->vm(), globalObject, globalObject->objectPrototype()); + prototypeStructure->setMayBePrototype(true); + JSGitCommitPrototype* prototype = JSGitCommitPrototype::create(globalObject->vm(), globalObject, prototypeStructure); + return JSGitCommit::createStructure(globalObject->vm(), globalObject, prototype); +} + +// ============================================================================ +// Module Creation (called from $cpp in git.ts) +// ============================================================================ + +JSC::JSValue createJSGitModule(Zig::GlobalObject* globalObject) +{ + JSC::VM& vm = globalObject->vm(); + + // Create the module object with Repository.open as a static method + JSC::JSObject* module = JSC::constructEmptyObject(globalObject); + + // Add Repository class/namespace with static methods + JSC::JSObject* repositoryObj = JSC::constructEmptyObject(globalObject); + repositoryObj->putDirect(vm, JSC::Identifier::fromString(vm, "open"_s), + JSC::JSFunction::create(vm, globalObject, 1, "open"_s, jsGitRepositoryOpen, ImplementationVisibility::Public)); + + module->putDirect(vm, JSC::Identifier::fromString(vm, "Repository"_s), repositoryObj); + + return module; +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/git/JSGit.h b/src/bun.js/bindings/git/JSGit.h new file mode 100644 index 0000000000..989caf9704 --- /dev/null +++ b/src/bun.js/bindings/git/JSGit.h @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2024 Oven-sh + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "root.h" +#include "ZigGlobalObject.h" + +#include +#include +#include + +#include "headers-handwritten.h" +#include "BunClientData.h" +#include + +// Forward declarations for libgit2 types +typedef struct git_repository git_repository; +typedef struct git_commit git_commit; +typedef struct git_oid git_oid; + +namespace WebCore { + +// Forward declarations +class JSGitRepository; +class JSGitCommit; +class JSGitOid; + +// JSGitRepository - Wraps git_repository* +class JSGitRepository final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSGitRepository* create(JSC::VM& vm, JSC::Structure* structure, git_repository* repo); + static void destroy(JSC::JSCell* cell); + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForJSGitRepository.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForJSGitRepository = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForJSGitRepository.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForJSGitRepository = std::forward(space); }); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + git_repository* repository() const { return m_repo; } + +private: + JSGitRepository(JSC::VM& vm, JSC::Structure* structure, git_repository* repo) + : Base(vm, structure) + , m_repo(repo) + { + } + + void finishCreation(JSC::VM& vm); + + git_repository* m_repo { nullptr }; +}; + +// JSGitCommit - Wraps git_commit* +class JSGitCommit final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSGitCommit* create(JSC::VM& vm, JSC::Structure* structure, git_commit* commit); + static void destroy(JSC::JSCell* cell); + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForJSGitCommit.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForJSGitCommit = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForJSGitCommit.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForJSGitCommit = std::forward(space); }); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + git_commit* commit() const { return m_commit; } + +private: + JSGitCommit(JSC::VM& vm, JSC::Structure* structure, git_commit* commit) + : Base(vm, structure) + , m_commit(commit) + { + } + + void finishCreation(JSC::VM& vm); + + git_commit* m_commit { nullptr }; +}; + +// Structure creation functions +JSC::Structure* createJSGitRepositoryStructure(JSC::JSGlobalObject* globalObject); +JSC::Structure* createJSGitCommitStructure(JSC::JSGlobalObject* globalObject); + +// Module creation function (called from $cpp) +JSC::JSValue createJSGitModule(Zig::GlobalObject* globalObject); + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h index 316f0848a7..45eed15434 100644 --- a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h @@ -24,6 +24,8 @@ public: std::unique_ptr m_clientSubspaceForNapiPrototype; std::unique_ptr m_clientSubspaceForJSSQLStatement; std::unique_ptr m_clientSubspaceForJSSQLStatementConstructor; + std::unique_ptr m_clientSubspaceForJSGitRepository; + std::unique_ptr m_clientSubspaceForJSGitCommit; std::unique_ptr m_clientSubspaceForJSSinkConstructor; std::unique_ptr m_clientSubspaceForJSSinkController; std::unique_ptr m_clientSubspaceForJSSink; diff --git a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h index b44973cb53..8789b96d17 100644 --- a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h @@ -24,6 +24,8 @@ public: std::unique_ptr m_subspaceForNapiPrototype; std::unique_ptr m_subspaceForJSSQLStatement; std::unique_ptr m_subspaceForJSSQLStatementConstructor; + std::unique_ptr m_subspaceForJSGitRepository; + std::unique_ptr m_subspaceForJSGitCommit; std::unique_ptr m_subspaceForJSSinkConstructor; std::unique_ptr m_subspaceForJSSinkController; std::unique_ptr m_subspaceForJSSink; diff --git a/src/js/bun/git.ts b/src/js/bun/git.ts new file mode 100644 index 0000000000..449f174b7d --- /dev/null +++ b/src/js/bun/git.ts @@ -0,0 +1,128 @@ +// Hardcoded module "bun:git" + +let Git: any; + +function initializeGit() { + Git = $cpp("git/JSGit.cpp", "createJSGitModule"); +} + +interface Signature { + name: string; + email: string; + time: number; // Unix timestamp in milliseconds +} + +interface StatusEntry { + path: string; + status: string; +} + +interface LogOptions { + from?: string; + limit?: number; +} + +class Repository { + #repo: any; + + constructor(repo: any) { + this.#repo = repo; + } + + /** + * Open an existing Git repository + */ + static open(path: string): Repository { + if (!Git) { + initializeGit(); + } + const repo = Git.Repository.open(path); + return new Repository(repo); + } + + /** + * Get the HEAD commit + */ + head(): Commit { + const commit = this.#repo.head(); + return new Commit(commit); + } + + /** + * Get the .git directory path + */ + get path(): string { + return this.#repo.path; + } + + /** + * Get the working directory path (null for bare repositories) + */ + get workdir(): string | null { + return this.#repo.workdir; + } + + /** + * Check if this is a bare repository + */ + get isBare(): boolean { + return this.#repo.isBare; + } +} + +class Commit { + #commit: any; + + constructor(commit: any) { + this.#commit = commit; + } + + /** + * Get the commit OID (SHA-1 hash) + */ + get id(): string { + return this.#commit.id; + } + + /** + * Get the full commit message + */ + get message(): string { + return this.#commit.message; + } + + /** + * Get the first line of the commit message + */ + get summary(): string { + return this.#commit.summary; + } + + /** + * Get the author signature + */ + get author(): Signature { + return this.#commit.author; + } + + /** + * Get the committer signature + */ + get committer(): Signature { + return this.#commit.committer; + } + + /** + * Get the commit time as Unix timestamp (seconds since epoch) + */ + get time(): number { + return this.#commit.time; + } +} + +export default { + __esModule: true, + Repository, + Commit, + default: Repository, +}; diff --git a/test/js/bun/git/repository.test.ts b/test/js/bun/git/repository.test.ts new file mode 100644 index 0000000000..2d1e5d4d3f --- /dev/null +++ b/test/js/bun/git/repository.test.ts @@ -0,0 +1,109 @@ +import { Commit, Repository } from "bun:git"; +import { describe, expect, test } from "bun:test"; + +describe("bun:git", () => { + describe("Repository", () => { + test("Repository.open opens the current repository", () => { + // Open the Bun repository itself + const repo = Repository.open("."); + + expect(repo).toBeInstanceOf(Repository); + expect(typeof repo.path).toBe("string"); + expect(repo.path).toContain(".git"); + }); + + test("Repository.path returns the .git directory path", () => { + const repo = Repository.open("."); + + expect(repo.path).toEndWith(".git/"); + }); + + test("Repository.workdir returns the working directory path", () => { + const repo = Repository.open("."); + + expect(repo.workdir).not.toBeNull(); + expect(typeof repo.workdir).toBe("string"); + }); + + test("Repository.isBare returns false for normal repositories", () => { + const repo = Repository.open("."); + + expect(repo.isBare).toBe(false); + }); + + test("Repository.open throws for non-existent path", () => { + expect(() => Repository.open("/nonexistent/path")).toThrow(); + }); + + test("Repository.open throws for non-repository path", () => { + expect(() => Repository.open("/tmp")).toThrow(); + }); + }); + + describe("Commit", () => { + test("Repository.head() returns a Commit object", () => { + const repo = Repository.open("."); + const head = repo.head(); + + expect(head).toBeInstanceOf(Commit); + }); + + test("Commit.id returns a 40-character hex string", () => { + const repo = Repository.open("."); + const head = repo.head(); + + expect(typeof head.id).toBe("string"); + expect(head.id).toMatch(/^[0-9a-f]{40}$/); + }); + + test("Commit.message returns the commit message", () => { + const repo = Repository.open("."); + const head = repo.head(); + + expect(typeof head.message).toBe("string"); + expect(head.message.length).toBeGreaterThan(0); + }); + + test("Commit.summary returns the first line of the message", () => { + const repo = Repository.open("."); + const head = repo.head(); + + expect(typeof head.summary).toBe("string"); + expect(head.summary.length).toBeGreaterThan(0); + // Summary should not contain newlines + expect(head.summary).not.toContain("\n"); + }); + + test("Commit.author returns a Signature object", () => { + const repo = Repository.open("."); + const head = repo.head(); + const author = head.author; + + expect(typeof author).toBe("object"); + expect(typeof author.name).toBe("string"); + expect(typeof author.email).toBe("string"); + expect(typeof author.time).toBe("number"); + }); + + test("Commit.committer returns a Signature object", () => { + const repo = Repository.open("."); + const head = repo.head(); + const committer = head.committer; + + expect(typeof committer).toBe("object"); + expect(typeof committer.name).toBe("string"); + expect(typeof committer.email).toBe("string"); + expect(typeof committer.time).toBe("number"); + }); + + test("Commit.time returns a Unix timestamp", () => { + const repo = Repository.open("."); + const head = repo.head(); + + expect(typeof head.time).toBe("number"); + expect(head.time).toBeGreaterThan(0); + // Should be a reasonable Unix timestamp (after 2020) + expect(head.time).toBeGreaterThan(1577836800); + }); + }); +});