From c9dc5dd381b687063fbabd5db02f3ba8d4c4ea5b Mon Sep 17 00:00:00 2001 From: Sosuke Suzuki Date: Wed, 4 Feb 2026 13:02:28 +0900 Subject: [PATCH] feat(git): add status, diff, log, and rev-parse APIs to bun:git Add read-only Git operations: getStatus, revParse, getCurrentBranch, aheadBehind, listFiles, diff, countCommits, and log. Includes Status and DeltaType constants for nodegit compatibility. Co-Authored-By: Claude Opus 4.5 --- packages/bun-types/git.d.ts | 452 ++++++++++++++++++ src/bun.js/bindings/git/JSGit.cpp | 714 +++++++++++++++++++++++++++++ src/js/bun/git.ts | 182 +++++++- test/js/bun/git/repository.test.ts | 279 ++++++++++- 4 files changed, 1624 insertions(+), 3 deletions(-) diff --git a/packages/bun-types/git.d.ts b/packages/bun-types/git.d.ts index cf10004a09..845d5ef4d1 100644 --- a/packages/bun-types/git.d.ts +++ b/packages/bun-types/git.d.ts @@ -40,6 +40,283 @@ declare module "bun:git" { readonly time: number; } + /** + * Status flags for working directory entries. + * These are bit flags that can be combined with bitwise OR. + * + * @example + * ```ts + * import { Status } from 'bun:git'; + * + * const entries = repo.getStatus(); + * for (const entry of entries) { + * if (entry.status & Status.WT_MODIFIED) { + * console.log('Modified in workdir:', entry.path); + * } + * if (entry.status & Status.INDEX_NEW) { + * console.log('New in index:', entry.path); + * } + * } + * ``` + */ + export const Status: { + /** Entry is current and unchanged */ + readonly CURRENT: 0; + /** Entry is new in the index */ + readonly INDEX_NEW: 1; + /** Entry is modified in the index */ + readonly INDEX_MODIFIED: 2; + /** Entry is deleted in the index */ + readonly INDEX_DELETED: 4; + /** Entry is renamed in the index */ + readonly INDEX_RENAMED: 8; + /** Entry type changed in the index */ + readonly INDEX_TYPECHANGE: 16; + /** Entry is new in the working tree */ + readonly WT_NEW: 128; + /** Entry is modified in the working tree */ + readonly WT_MODIFIED: 256; + /** Entry is deleted in the working tree */ + readonly WT_DELETED: 512; + /** Entry type changed in the working tree */ + readonly WT_TYPECHANGE: 1024; + /** Entry is renamed in the working tree */ + readonly WT_RENAMED: 2048; + /** Entry is ignored */ + readonly IGNORED: 16384; + /** Entry is conflicted */ + readonly CONFLICTED: 32768; + }; + + /** + * Delta types for diff entries. + * + * @example + * ```ts + * import { DeltaType } from 'bun:git'; + * + * const diff = repo.diff(); + * for (const file of diff.files) { + * if (file.status === DeltaType.ADDED) { + * console.log('Added:', file.newPath); + * } + * } + * ``` + */ + export const DeltaType: { + /** No changes */ + readonly UNMODIFIED: 0; + /** Entry does not exist in old version */ + readonly ADDED: 1; + /** Entry does not exist in new version */ + readonly DELETED: 2; + /** Entry content changed between old and new */ + readonly MODIFIED: 3; + /** Entry was renamed between old and new */ + readonly RENAMED: 4; + /** Entry was copied from another old entry */ + readonly COPIED: 5; + /** Entry is ignored item in workdir */ + readonly IGNORED: 6; + /** Entry is untracked item in workdir */ + readonly UNTRACKED: 7; + /** Entry type changed between old and new */ + readonly TYPECHANGE: 8; + /** Entry is unreadable */ + readonly CONFLICTED: 10; + }; + + /** + * Options for getting repository status. + */ + export interface StatusOptions { + /** + * Include untracked files in the status. + * @default true + */ + includeUntracked?: boolean; + + /** + * Include ignored files in the status. + * @default false + */ + includeIgnored?: boolean; + + /** + * Recurse into untracked directories. + * @default true + */ + recurseUntrackedDirs?: boolean; + + /** + * Detect renamed files. + * @default false + */ + detectRenames?: boolean; + } + + /** + * Represents a status entry for a file in the working directory. + */ + export class StatusEntry { + /** + * The path of the file relative to the repository root. + */ + readonly path: string; + + /** + * Status flags (combination of Status values). + */ + readonly status: number; + + /** + * Check if the entry is new (untracked or staged as new). + */ + isNew(): boolean; + + /** + * Check if the entry is modified. + */ + isModified(): boolean; + + /** + * Check if the entry is deleted. + */ + isDeleted(): boolean; + + /** + * Check if the entry is renamed. + */ + isRenamed(): boolean; + + /** + * Check if the entry is ignored. + */ + isIgnored(): boolean; + + /** + * Check if the entry has changes staged in the index. + */ + inIndex(): boolean; + + /** + * Check if the entry has changes in the working tree. + */ + inWorkingTree(): boolean; + } + + /** + * Represents an entry in the Git index. + */ + export interface IndexEntry { + /** + * The path of the file relative to the repository root. + */ + readonly path: string; + + /** + * The file mode (e.g., 0o100644 for regular files). + */ + readonly mode: number; + + /** + * The blob OID (SHA-1 hash) of the file content. + */ + readonly oid: string; + + /** + * The stage number (0 for normal, 1-3 for conflict stages). + */ + readonly stage: number; + + /** + * The file size in bytes. + */ + readonly size: number; + } + + /** + * Options for getting diff information. + */ + export interface DiffOptions { + /** + * If true, compare HEAD to index (staged changes). + * If false, compare HEAD to working directory. + * @default false + */ + cached?: boolean; + } + + /** + * Represents a changed file in a diff. + */ + export interface DiffFile { + /** + * The type of change (see DeltaType). + */ + readonly status: number; + + /** + * The old path (null for added files). + */ + readonly oldPath: string | null; + + /** + * The new path. + */ + readonly newPath: string; + + /** + * Similarity percentage for renamed/copied files (0-100). + */ + readonly similarity?: number; + } + + /** + * Result of a diff operation. + */ + export interface DiffResult { + /** + * List of changed files. + */ + readonly files: DiffFile[]; + + /** + * Statistics about the diff. + */ + readonly stats: { + /** Number of files changed */ + readonly filesChanged: number; + /** Total lines inserted */ + readonly insertions: number; + /** Total lines deleted */ + readonly deletions: number; + }; + } + + /** + * Options for getting commit history. + */ + export interface LogOptions { + /** + * Starting point for history traversal. + * @default "HEAD" + */ + from?: string; + + /** + * Range specification (e.g., "origin/main..HEAD"). + * If provided, `from` is ignored. + */ + range?: string; + + /** + * Maximum number of commits to return. + * @default unlimited + */ + limit?: number; + } + /** * Represents a Git commit object. * @@ -184,6 +461,181 @@ declare module "bun:git" { * ``` */ readonly isBare: boolean; + + /** + * Gets the working directory status. + * + * Returns an array of status entries for all changed files in the + * working directory and index. + * + * @param options Options to control which files are included + * @returns Array of status entries + * + * @example + * ```ts + * import { Repository, Status } from 'bun:git'; + * + * const repo = Repository.open('.'); + * const status = repo.getStatus(); + * + * for (const entry of status) { + * if (entry.isModified()) { + * console.log('Modified:', entry.path); + * } + * if (entry.isNew()) { + * console.log('New:', entry.path); + * } + * } + * ``` + */ + getStatus(options?: StatusOptions): StatusEntry[]; + + /** + * Resolves a revision specification to a commit OID. + * + * Supports standard Git revision syntax including: + * - Branch names: "main", "feature/foo" + * - Tag names: "v1.0.0" + * - SHA prefixes: "abc123" + * - Special refs: "HEAD", "HEAD~1", "HEAD^2" + * - Upstream: "@{u}", "main@{u}" + * + * @param spec The revision specification to resolve + * @returns The 40-character hex OID + * @throws Error if the spec cannot be resolved + * + * @example + * ```ts + * const headOid = repo.revParse('HEAD'); + * const parentOid = repo.revParse('HEAD~1'); + * const branchOid = repo.revParse('main'); + * ``` + */ + revParse(spec: string): string; + + /** + * Gets the name of the current branch. + * + * @returns The branch name, or null if HEAD is detached or unborn + * + * @example + * ```ts + * const branch = repo.getCurrentBranch(); + * if (branch) { + * console.log('On branch:', branch); + * } else { + * console.log('HEAD is detached'); + * } + * ``` + */ + getCurrentBranch(): string | null; + + /** + * Gets the ahead/behind counts between two commits. + * + * This is useful for comparing a local branch to its upstream. + * + * @param local The local ref (default: "HEAD") + * @param upstream The upstream ref (default: "@{u}") + * @returns Object with ahead and behind counts + * + * @example + * ```ts + * const { ahead, behind } = repo.aheadBehind(); + * console.log(`${ahead} ahead, ${behind} behind`); + * + * // Compare specific refs + * const { ahead, behind } = repo.aheadBehind('feature', 'origin/main'); + * ``` + */ + aheadBehind(local?: string, upstream?: string): { ahead: number; behind: number }; + + /** + * Gets the list of files tracked in the index. + * + * @returns Array of index entries + * + * @example + * ```ts + * const files = repo.listFiles(); + * console.log(`Tracking ${files.length} files`); + * + * for (const file of files) { + * console.log(`${file.path} (mode: ${file.mode.toString(8)})`); + * } + * ``` + */ + listFiles(): IndexEntry[]; + + /** + * Gets diff information between HEAD and working directory or index. + * + * @param options Options to control the diff behavior + * @returns Diff result with file list and statistics + * + * @example + * ```ts + * import { Repository, DeltaType } from 'bun:git'; + * + * const repo = Repository.open('.'); + * + * // Unstaged changes (HEAD vs workdir) + * const diff = repo.diff(); + * console.log(`${diff.stats.filesChanged} files changed`); + * console.log(`+${diff.stats.insertions} -${diff.stats.deletions}`); + * + * // Staged changes (HEAD vs index) + * const staged = repo.diff({ cached: true }); + * + * for (const file of diff.files) { + * if (file.status === DeltaType.MODIFIED) { + * console.log('Modified:', file.newPath); + * } + * } + * ``` + */ + diff(options?: DiffOptions): DiffResult; + + /** + * Counts the number of commits in a range. + * + * @param range Optional range specification (e.g., "origin/main..HEAD") + * @returns Number of commits + * + * @example + * ```ts + * // Total commits + * const total = repo.countCommits(); + * + * // Commits since origin/main + * const since = repo.countCommits('origin/main..HEAD'); + * ``` + */ + countCommits(range?: string): number; + + /** + * Gets the commit history. + * + * @param options Options to control the log behavior + * @returns Array of commits + * + * @example + * ```ts + * // Last 10 commits + * const commits = repo.log({ limit: 10 }); + * + * for (const commit of commits) { + * console.log(`${commit.id.slice(0, 7)} ${commit.summary}`); + * } + * + * // Commits in a range + * const range = repo.log({ range: 'origin/main..HEAD' }); + * + * // Commits from a specific ref + * const fromTag = repo.log({ from: 'v1.0.0', limit: 5 }); + * ``` + */ + log(options?: LogOptions): Commit[]; } export default Repository; diff --git a/src/bun.js/bindings/git/JSGit.cpp b/src/bun.js/bindings/git/JSGit.cpp index 39a82cf0a8..61bcdeec23 100644 --- a/src/bun.js/bindings/git/JSGit.cpp +++ b/src/bun.js/bindings/git/JSGit.cpp @@ -223,11 +223,725 @@ JSC_DEFINE_CUSTOM_GETTER(jsGitRepositoryIsBare, (JSC::JSGlobalObject * lexicalGl return JSC::JSValue::encode(JSC::jsBoolean(git_repository_is_bare(repo))); } +// ============================================================================ +// getStatus - Get working directory status +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryGetStatus, (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()); + } + + // Parse options + git_status_options opts = GIT_STATUS_OPTIONS_INIT; + opts.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; + opts.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS; + + if (callFrame->argumentCount() > 0) { + JSC::JSValue optionsValue = callFrame->argument(0); + if (optionsValue.isObject()) { + JSC::JSObject* options = optionsValue.toObject(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + + JSC::JSValue includeUntracked = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "includeUntracked"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!includeUntracked.isUndefined() && !includeUntracked.toBoolean(lexicalGlobalObject)) { + opts.flags &= ~GIT_STATUS_OPT_INCLUDE_UNTRACKED; + } + + JSC::JSValue includeIgnored = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "includeIgnored"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!includeIgnored.isUndefined() && includeIgnored.toBoolean(lexicalGlobalObject)) { + opts.flags |= GIT_STATUS_OPT_INCLUDE_IGNORED; + } + + JSC::JSValue recurseUntrackedDirs = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "recurseUntrackedDirs"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!recurseUntrackedDirs.isUndefined() && !recurseUntrackedDirs.toBoolean(lexicalGlobalObject)) { + opts.flags &= ~GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS; + } + + JSC::JSValue detectRenames = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "detectRenames"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!detectRenames.isUndefined() && detectRenames.toBoolean(lexicalGlobalObject)) { + opts.flags |= GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | GIT_STATUS_OPT_RENAMES_INDEX_TO_WORKDIR; + } + } + } + + git_status_list* statusList = nullptr; + int error = git_status_list_new(&statusList, repo, &opts); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to get status")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + size_t count = git_status_list_entrycount(statusList); + JSC::JSArray* result = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, count); + RETURN_IF_EXCEPTION(scope, {}); + + for (size_t i = 0; i < count; i++) { + const git_status_entry* entry = git_status_byindex(statusList, i); + if (!entry) + continue; + + JSC::JSObject* entryObj = JSC::constructEmptyObject(lexicalGlobalObject); + + // Get the path (from either index or workdir) + const char* path = nullptr; + if (entry->head_to_index && entry->head_to_index->new_file.path) { + path = entry->head_to_index->new_file.path; + } else if (entry->index_to_workdir && entry->index_to_workdir->new_file.path) { + path = entry->index_to_workdir->new_file.path; + } else if (entry->head_to_index && entry->head_to_index->old_file.path) { + path = entry->head_to_index->old_file.path; + } else if (entry->index_to_workdir && entry->index_to_workdir->old_file.path) { + path = entry->index_to_workdir->old_file.path; + } + + if (path) { + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "path"_s), + JSC::jsString(vm, WTF::String::fromUTF8(path))); + } else { + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "path"_s), JSC::jsEmptyString(vm)); + } + + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "status"_s), + JSC::jsNumber(static_cast(entry->status))); + + result->putDirectIndex(lexicalGlobalObject, i, entryObj); + RETURN_IF_EXCEPTION(scope, {}); + } + + git_status_list_free(statusList); + return JSC::JSValue::encode(result); +} + +// ============================================================================ +// revParse - Resolve a revision spec to an OID +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryRevParse, (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()); + } + + if (callFrame->argumentCount() < 1) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "revParse requires a spec argument"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::JSValue specValue = callFrame->argument(0); + if (!specValue.isString()) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Spec must be a string"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + WTF::String specString = specValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + WTF::CString specCString = specString.utf8(); + + git_object* obj = nullptr; + int error = git_revparse_single(&obj, repo, specCString.data()); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to parse revision spec")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + const git_oid* oid = git_object_id(obj); + char oidStr[GIT_OID_SHA1_HEXSIZE + 1]; + git_oid_tostr(oidStr, sizeof(oidStr), oid); + + git_object_free(obj); + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String::fromUTF8(oidStr))); +} + +// ============================================================================ +// getCurrentBranch - Get the name of the current branch +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryGetCurrentBranch, (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); + + // GIT_EUNBORNBRANCH means HEAD points to a branch that doesn't exist yet + if (error == GIT_EUNBORNBRANCH || error == GIT_ENOTFOUND) { + return JSC::JSValue::encode(JSC::jsNull()); + } + + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to get HEAD")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + // Check if HEAD is detached + if (git_repository_head_detached(repo)) { + git_reference_free(headRef); + return JSC::JSValue::encode(JSC::jsNull()); + } + + // Get the branch name (strip refs/heads/ prefix) + const char* branchName = git_reference_shorthand(headRef); + JSC::JSValue result = JSC::jsString(vm, WTF::String::fromUTF8(branchName)); + + git_reference_free(headRef); + return JSC::JSValue::encode(result); +} + +// ============================================================================ +// aheadBehind - Get ahead/behind counts between two commits +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryAheadBehind, (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()); + } + + // Default to HEAD and @{u} (upstream) + WTF::CString localSpec = WTF::String("HEAD"_s).utf8(); + WTF::CString upstreamSpec; + + // Parse arguments + if (callFrame->argumentCount() > 0 && !callFrame->argument(0).isUndefinedOrNull()) { + JSC::JSValue localValue = callFrame->argument(0); + if (!localValue.isString()) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Local must be a string"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + WTF::String localString = localValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + localSpec = localString.utf8(); + } + + if (callFrame->argumentCount() > 1 && !callFrame->argument(1).isUndefinedOrNull()) { + JSC::JSValue upstreamValue = callFrame->argument(1); + if (!upstreamValue.isString()) { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Upstream must be a string"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + WTF::String upstreamString = upstreamValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + upstreamSpec = upstreamString.utf8(); + } + + // Get the local OID + git_object* localObj = nullptr; + int error = git_revparse_single(&localObj, repo, localSpec.data()); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to resolve local ref")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + git_oid localOid = *git_object_id(localObj); + git_object_free(localObj); + + // Get the upstream OID + git_oid upstreamOid; + if (upstreamSpec.length() == 0) { + // Try to get upstream from @{u} + git_object* upstreamObj = nullptr; + error = git_revparse_single(&upstreamObj, repo, "@{u}"); + if (error < 0) { + // No upstream configured, return 0 for both + JSC::JSObject* result = JSC::constructEmptyObject(lexicalGlobalObject); + result->putDirect(vm, JSC::Identifier::fromString(vm, "ahead"_s), JSC::jsNumber(0)); + result->putDirect(vm, JSC::Identifier::fromString(vm, "behind"_s), JSC::jsNumber(0)); + return JSC::JSValue::encode(result); + } + upstreamOid = *git_object_id(upstreamObj); + git_object_free(upstreamObj); + } else { + git_object* upstreamObj = nullptr; + error = git_revparse_single(&upstreamObj, repo, upstreamSpec.data()); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to resolve upstream ref")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + upstreamOid = *git_object_id(upstreamObj); + git_object_free(upstreamObj); + } + + size_t ahead = 0, behind = 0; + error = git_graph_ahead_behind(&ahead, &behind, repo, &localOid, &upstreamOid); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to compute ahead/behind")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::JSObject* result = JSC::constructEmptyObject(lexicalGlobalObject); + result->putDirect(vm, JSC::Identifier::fromString(vm, "ahead"_s), JSC::jsNumber(static_cast(ahead))); + result->putDirect(vm, JSC::Identifier::fromString(vm, "behind"_s), JSC::jsNumber(static_cast(behind))); + + return JSC::JSValue::encode(result); +} + +// ============================================================================ +// listFiles - Get list of files in the index +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryListFiles, (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_index* index = nullptr; + int error = git_repository_index(&index, repo); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to get repository index")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + size_t count = git_index_entrycount(index); + JSC::JSArray* result = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, count); + RETURN_IF_EXCEPTION(scope, {}); + + for (size_t i = 0; i < count; i++) { + const git_index_entry* entry = git_index_get_byindex(index, i); + if (!entry) + continue; + + JSC::JSObject* entryObj = JSC::constructEmptyObject(lexicalGlobalObject); + + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "path"_s), + JSC::jsString(vm, WTF::String::fromUTF8(entry->path))); + + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "mode"_s), + JSC::jsNumber(static_cast(entry->mode))); + + char oidStr[GIT_OID_SHA1_HEXSIZE + 1]; + git_oid_tostr(oidStr, sizeof(oidStr), &entry->id); + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "oid"_s), + JSC::jsString(vm, WTF::String::fromUTF8(oidStr))); + + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "stage"_s), + JSC::jsNumber(GIT_INDEX_ENTRY_STAGE(entry))); + + entryObj->putDirect(vm, JSC::Identifier::fromString(vm, "size"_s), + JSC::jsNumber(static_cast(entry->file_size))); + + result->putDirectIndex(lexicalGlobalObject, i, entryObj); + RETURN_IF_EXCEPTION(scope, {}); + } + + git_index_free(index); + return JSC::JSValue::encode(result); +} + +// ============================================================================ +// diff - Get diff information +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryDiff, (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()); + } + + bool cached = false; + + // Parse options + if (callFrame->argumentCount() > 0) { + JSC::JSValue optionsValue = callFrame->argument(0); + if (optionsValue.isObject()) { + JSC::JSObject* options = optionsValue.toObject(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + + JSC::JSValue cachedValue = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "cached"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!cachedValue.isUndefined()) { + cached = cachedValue.toBoolean(lexicalGlobalObject); + } + } + } + + // Get HEAD tree + git_reference* headRef = nullptr; + git_commit* headCommit = nullptr; + git_tree* headTree = nullptr; + + int error = git_repository_head(&headRef, repo); + if (error == 0) { + const git_oid* oid = git_reference_target(headRef); + if (oid) { + error = git_commit_lookup(&headCommit, repo, oid); + if (error == 0) { + error = git_commit_tree(&headTree, headCommit); + } + } + git_reference_free(headRef); + } + + git_diff* diff = nullptr; + git_diff_options diffOpts = GIT_DIFF_OPTIONS_INIT; + + if (cached) { + // HEAD vs index + error = git_diff_tree_to_index(&diff, repo, headTree, nullptr, &diffOpts); + } else { + // HEAD vs workdir (with index) + error = git_diff_tree_to_workdir_with_index(&diff, repo, headTree, &diffOpts); + } + + if (headTree) + git_tree_free(headTree); + if (headCommit) + git_commit_free(headCommit); + + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to create diff")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + // Get stats + git_diff_stats* stats = nullptr; + error = git_diff_get_stats(&stats, diff); + if (error < 0) { + git_diff_free(diff); + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to get diff stats")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + size_t filesChanged = git_diff_stats_files_changed(stats); + size_t insertions = git_diff_stats_insertions(stats); + size_t deletions = git_diff_stats_deletions(stats); + git_diff_stats_free(stats); + + // Get file list + size_t numDeltas = git_diff_num_deltas(diff); + JSC::JSArray* files = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, numDeltas); + RETURN_IF_EXCEPTION(scope, {}); + + for (size_t i = 0; i < numDeltas; i++) { + const git_diff_delta* delta = git_diff_get_delta(diff, i); + if (!delta) + continue; + + JSC::JSObject* fileObj = JSC::constructEmptyObject(lexicalGlobalObject); + + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "status"_s), + JSC::jsNumber(static_cast(delta->status))); + + if (delta->old_file.path) { + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "oldPath"_s), + JSC::jsString(vm, WTF::String::fromUTF8(delta->old_file.path))); + } else { + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "oldPath"_s), JSC::jsNull()); + } + + if (delta->new_file.path) { + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "newPath"_s), + JSC::jsString(vm, WTF::String::fromUTF8(delta->new_file.path))); + } else { + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "newPath"_s), JSC::jsNull()); + } + + if (delta->similarity > 0) { + fileObj->putDirect(vm, JSC::Identifier::fromString(vm, "similarity"_s), + JSC::jsNumber(static_cast(delta->similarity))); + } + + files->putDirectIndex(lexicalGlobalObject, i, fileObj); + RETURN_IF_EXCEPTION(scope, {}); + } + + git_diff_free(diff); + + // Build result object + JSC::JSObject* result = JSC::constructEmptyObject(lexicalGlobalObject); + result->putDirect(vm, JSC::Identifier::fromString(vm, "files"_s), files); + + JSC::JSObject* statsObj = JSC::constructEmptyObject(lexicalGlobalObject); + statsObj->putDirect(vm, JSC::Identifier::fromString(vm, "filesChanged"_s), + JSC::jsNumber(static_cast(filesChanged))); + statsObj->putDirect(vm, JSC::Identifier::fromString(vm, "insertions"_s), + JSC::jsNumber(static_cast(insertions))); + statsObj->putDirect(vm, JSC::Identifier::fromString(vm, "deletions"_s), + JSC::jsNumber(static_cast(deletions))); + result->putDirect(vm, JSC::Identifier::fromString(vm, "stats"_s), statsObj); + + return JSC::JSValue::encode(result); +} + +// ============================================================================ +// countCommits - Count commits in a range +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryCountCommits, (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_revwalk* walk = nullptr; + int error = git_revwalk_new(&walk, repo); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to create revwalk")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + // Parse range argument + if (callFrame->argumentCount() > 0 && !callFrame->argument(0).isUndefinedOrNull()) { + JSC::JSValue rangeValue = callFrame->argument(0); + if (!rangeValue.isString()) { + git_revwalk_free(walk); + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "Range must be a string"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + WTF::String rangeString = rangeValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + WTF::CString rangeCString = rangeString.utf8(); + + error = git_revwalk_push_range(walk, rangeCString.data()); + if (error < 0) { + git_revwalk_free(walk); + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to set range")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + } else { + // Default to HEAD + error = git_revwalk_push_head(walk); + if (error < 0) { + git_revwalk_free(walk); + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to push HEAD")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + } + + git_revwalk_sorting(walk, GIT_SORT_TIME); + + git_oid oid; + size_t count = 0; + while (git_revwalk_next(&oid, walk) == 0) { + count++; + } + + git_revwalk_free(walk); + return JSC::JSValue::encode(JSC::jsNumber(static_cast(count))); +} + +// ============================================================================ +// log - Get commit history +// ============================================================================ + +JSC_DEFINE_HOST_FUNCTION(jsGitRepositoryLog, (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()); + } + + // Parse options + WTF::CString fromSpec = WTF::String("HEAD"_s).utf8(); + WTF::CString rangeSpec; + int limit = -1; // -1 means no limit + + if (callFrame->argumentCount() > 0) { + JSC::JSValue optionsValue = callFrame->argument(0); + if (optionsValue.isObject()) { + JSC::JSObject* options = optionsValue.toObject(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + + JSC::JSValue fromValue = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "from"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!fromValue.isUndefined() && fromValue.isString()) { + WTF::String fromString = fromValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + fromSpec = fromString.utf8(); + } + + JSC::JSValue rangeValue = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "range"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!rangeValue.isUndefined() && rangeValue.isString()) { + WTF::String rangeString = rangeValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + rangeSpec = rangeString.utf8(); + } + + JSC::JSValue limitValue = options->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "limit"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!limitValue.isUndefined() && limitValue.isNumber()) { + limit = limitValue.toInt32(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + } + } + + git_revwalk* walk = nullptr; + int error = git_revwalk_new(&walk, repo); + if (error < 0) { + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to create revwalk")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + if (rangeSpec.length() > 0) { + error = git_revwalk_push_range(walk, rangeSpec.data()); + } else { + git_object* fromObj = nullptr; + error = git_revparse_single(&fromObj, repo, fromSpec.data()); + if (error == 0) { + error = git_revwalk_push(walk, git_object_id(fromObj)); + git_object_free(fromObj); + } + } + + if (error < 0) { + git_revwalk_free(walk); + throwException(lexicalGlobalObject, scope, createGitError(lexicalGlobalObject, "Failed to set revwalk range")); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + git_revwalk_sorting(walk, GIT_SORT_TIME); + + auto* globalObject = JSC::jsDynamicCast(lexicalGlobalObject); + if (!globalObject) { + git_revwalk_free(walk); + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid global object"_s)); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + + JSC::Structure* commitStructure = globalObject->JSGitCommitStructure(); + + // Collect commits + Vector> commits; + git_oid oid; + int count = 0; + while (git_revwalk_next(&oid, walk) == 0) { + if (limit >= 0 && count >= limit) + break; + + git_commit* commit = nullptr; + error = git_commit_lookup(&commit, repo, &oid); + if (error < 0) + continue; + + JSGitCommit* jsCommit = JSGitCommit::create(vm, commitStructure, commit); + commits.append(JSC::Strong(vm, jsCommit)); + count++; + } + + git_revwalk_free(walk); + + // Create result array + JSC::JSArray* result = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, commits.size()); + RETURN_IF_EXCEPTION(scope, {}); + + for (size_t i = 0; i < commits.size(); i++) { + result->putDirectIndex(lexicalGlobalObject, i, commits[i].get()); + RETURN_IF_EXCEPTION(scope, {}); + } + + return JSC::JSValue::encode(result); +} + 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 } }, + { "getStatus"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryGetStatus, 1 } }, + { "revParse"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryRevParse, 1 } }, + { "getCurrentBranch"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryGetCurrentBranch, 0 } }, + { "aheadBehind"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryAheadBehind, 2 } }, + { "listFiles"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryListFiles, 0 } }, + { "diff"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryDiff, 1 } }, + { "countCommits"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryCountCommits, 1 } }, + { "log"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryLog, 1 } }, }; class JSGitRepositoryPrototype final : public JSC::JSNonFinalObject { diff --git a/src/js/bun/git.ts b/src/js/bun/git.ts index 449f174b7d..cf023e24f5 100644 --- a/src/js/bun/git.ts +++ b/src/js/bun/git.ts @@ -12,16 +12,133 @@ interface Signature { time: number; // Unix timestamp in milliseconds } -interface StatusEntry { +interface StatusOptions { + includeUntracked?: boolean; + includeIgnored?: boolean; + recurseUntrackedDirs?: boolean; + detectRenames?: boolean; +} + +interface InternalStatusEntry { path: string; - status: string; + status: number; +} + +interface IndexEntry { + path: string; + mode: number; + oid: string; + stage: number; + size: number; +} + +interface DiffOptions { + cached?: boolean; +} + +interface DiffFile { + status: number; + oldPath: string | null; + newPath: string; + similarity?: number; +} + +interface DiffResult { + files: DiffFile[]; + stats: { + filesChanged: number; + insertions: number; + deletions: number; + }; } interface LogOptions { from?: string; + range?: string; limit?: number; } +// Status constants (nodegit compatible) +const Status = { + CURRENT: 0, + INDEX_NEW: 1, + INDEX_MODIFIED: 2, + INDEX_DELETED: 4, + INDEX_RENAMED: 8, + INDEX_TYPECHANGE: 16, + WT_NEW: 128, + WT_MODIFIED: 256, + WT_DELETED: 512, + WT_TYPECHANGE: 1024, + WT_RENAMED: 2048, + IGNORED: 16384, + CONFLICTED: 32768, +}; + +// DeltaType constants (nodegit compatible) +const DeltaType = { + UNMODIFIED: 0, + ADDED: 1, + DELETED: 2, + MODIFIED: 3, + RENAMED: 4, + COPIED: 5, + IGNORED: 6, + UNTRACKED: 7, + TYPECHANGE: 8, + CONFLICTED: 10, +}; + +class StatusEntry { + path: string; + status: number; + + constructor(entry: InternalStatusEntry) { + this.path = entry.path; + this.status = entry.status; + } + + isNew(): boolean { + return (this.status & (Status.INDEX_NEW | Status.WT_NEW)) !== 0; + } + + isModified(): boolean { + return (this.status & (Status.INDEX_MODIFIED | Status.WT_MODIFIED)) !== 0; + } + + isDeleted(): boolean { + return (this.status & (Status.INDEX_DELETED | Status.WT_DELETED)) !== 0; + } + + isRenamed(): boolean { + return (this.status & (Status.INDEX_RENAMED | Status.WT_RENAMED)) !== 0; + } + + isIgnored(): boolean { + return (this.status & Status.IGNORED) !== 0; + } + + inIndex(): boolean { + return ( + (this.status & + (Status.INDEX_NEW | + Status.INDEX_MODIFIED | + Status.INDEX_DELETED | + Status.INDEX_RENAMED | + Status.INDEX_TYPECHANGE)) !== + 0 + ); + } + + inWorkingTree(): boolean { + return ( + (this.status & + (Status.WT_NEW | Status.WT_MODIFIED | Status.WT_DELETED | Status.WT_TYPECHANGE | Status.WT_RENAMED)) !== + 0 + ); + } +} + class Repository { #repo: any; @@ -68,6 +185,64 @@ class Repository { get isBare(): boolean { return this.#repo.isBare; } + + /** + * Get the working directory status (nodegit compatible) + */ + getStatus(options?: StatusOptions): StatusEntry[] { + const entries = this.#repo.getStatus(options); + return entries.map((e: InternalStatusEntry) => new StatusEntry(e)); + } + + /** + * Resolve a revision spec to an OID + */ + revParse(spec: string): string { + return this.#repo.revParse(spec); + } + + /** + * Get the name of the current branch (null if detached HEAD or no commits) + */ + getCurrentBranch(): string | null { + return this.#repo.getCurrentBranch(); + } + + /** + * Get ahead/behind counts between two commits + */ + aheadBehind(local?: string, upstream?: string): { ahead: number; behind: number } { + return this.#repo.aheadBehind(local, upstream); + } + + /** + * Get list of files in the index + */ + listFiles(): IndexEntry[] { + return this.#repo.listFiles(); + } + + /** + * Get diff information + */ + diff(options?: DiffOptions): DiffResult { + return this.#repo.diff(options); + } + + /** + * Count commits in a range + */ + countCommits(range?: string): number { + return this.#repo.countCommits(range); + } + + /** + * Get commit history + */ + log(options?: LogOptions): Commit[] { + const commits = this.#repo.log(options); + return commits.map((c: any) => new Commit(c)); + } } class Commit { @@ -124,5 +299,8 @@ export default { __esModule: true, Repository, Commit, + StatusEntry, + Status, + DeltaType, default: Repository, }; diff --git a/test/js/bun/git/repository.test.ts b/test/js/bun/git/repository.test.ts index 2d1e5d4d3f..d0137520b4 100644 --- a/test/js/bun/git/repository.test.ts +++ b/test/js/bun/git/repository.test.ts @@ -1,4 +1,4 @@ -import { Commit, Repository } from "bun:git"; +import { Commit, DeltaType, Repository, Status, StatusEntry } from "bun:git"; import { describe, expect, test } from "bun:test"; describe("bun:git", () => { @@ -106,4 +106,281 @@ describe("bun:git", () => { expect(head.time).toBeGreaterThan(1577836800); }); }); + + describe("getStatus", () => { + test("getStatus returns an array of StatusEntry", () => { + const repo = Repository.open("."); + const status = repo.getStatus(); + + expect(Array.isArray(status)).toBe(true); + // Each entry should have path and status + for (const entry of status) { + expect(entry).toBeInstanceOf(StatusEntry); + expect(typeof entry.path).toBe("string"); + expect(typeof entry.status).toBe("number"); + } + }); + + test("StatusEntry has helper methods", () => { + const repo = Repository.open("."); + const status = repo.getStatus(); + + // Test that helper methods exist and return booleans + for (const entry of status) { + expect(typeof entry.isNew()).toBe("boolean"); + expect(typeof entry.isModified()).toBe("boolean"); + expect(typeof entry.isDeleted()).toBe("boolean"); + expect(typeof entry.isRenamed()).toBe("boolean"); + expect(typeof entry.isIgnored()).toBe("boolean"); + expect(typeof entry.inIndex()).toBe("boolean"); + expect(typeof entry.inWorkingTree()).toBe("boolean"); + } + }); + + test("getStatus with includeUntracked option", () => { + const repo = Repository.open("."); + + const withUntracked = repo.getStatus({ includeUntracked: true }); + const withoutUntracked = repo.getStatus({ includeUntracked: false }); + + // Both should be arrays + expect(Array.isArray(withUntracked)).toBe(true); + expect(Array.isArray(withoutUntracked)).toBe(true); + }); + + test("Status constants are defined", () => { + expect(Status.CURRENT).toBe(0); + expect(Status.INDEX_NEW).toBe(1); + expect(Status.INDEX_MODIFIED).toBe(2); + expect(Status.INDEX_DELETED).toBe(4); + expect(Status.INDEX_RENAMED).toBe(8); + expect(Status.WT_NEW).toBe(128); + expect(Status.WT_MODIFIED).toBe(256); + expect(Status.WT_DELETED).toBe(512); + expect(Status.IGNORED).toBe(16384); + expect(Status.CONFLICTED).toBe(32768); + }); + }); + + describe("revParse", () => { + test("revParse resolves HEAD", () => { + const repo = Repository.open("."); + const oid = repo.revParse("HEAD"); + + expect(typeof oid).toBe("string"); + expect(oid).toMatch(/^[0-9a-f]{40}$/); + }); + + test("revParse resolves HEAD~1", () => { + const repo = Repository.open("."); + const head = repo.revParse("HEAD"); + const parent = repo.revParse("HEAD~1"); + + expect(typeof parent).toBe("string"); + expect(parent).toMatch(/^[0-9a-f]{40}$/); + // Parent should be different from HEAD + expect(parent).not.toBe(head); + }); + + test("revParse throws for invalid spec", () => { + const repo = Repository.open("."); + + expect(() => repo.revParse("invalid-ref-that-does-not-exist")).toThrow(); + }); + }); + + describe("getCurrentBranch", () => { + test("getCurrentBranch returns a string or null", () => { + const repo = Repository.open("."); + const branch = repo.getCurrentBranch(); + + // It's either a string (branch name) or null (detached HEAD) + if (branch !== null) { + expect(typeof branch).toBe("string"); + expect(branch.length).toBeGreaterThan(0); + } + }); + }); + + describe("aheadBehind", () => { + test("aheadBehind returns ahead and behind counts", () => { + const repo = Repository.open("."); + + // This may return {ahead: 0, behind: 0} if no upstream is set + const result = repo.aheadBehind(); + + expect(typeof result).toBe("object"); + expect(typeof result.ahead).toBe("number"); + expect(typeof result.behind).toBe("number"); + expect(result.ahead).toBeGreaterThanOrEqual(0); + expect(result.behind).toBeGreaterThanOrEqual(0); + }); + + test("aheadBehind with explicit refs", () => { + const repo = Repository.open("."); + + // Compare HEAD~5 to HEAD + const result = repo.aheadBehind("HEAD", "HEAD~5"); + + expect(typeof result.ahead).toBe("number"); + expect(typeof result.behind).toBe("number"); + // HEAD should be 5 ahead of HEAD~5 + expect(result.ahead).toBe(5); + expect(result.behind).toBe(0); + }); + }); + + describe("listFiles", () => { + test("listFiles returns an array of IndexEntry", () => { + const repo = Repository.open("."); + const files = repo.listFiles(); + + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBeGreaterThan(0); + + // Check structure of entries + for (const entry of files.slice(0, 5)) { + expect(typeof entry.path).toBe("string"); + expect(typeof entry.mode).toBe("number"); + expect(typeof entry.oid).toBe("string"); + expect(entry.oid).toMatch(/^[0-9a-f]{40}$/); + expect(typeof entry.stage).toBe("number"); + expect(typeof entry.size).toBe("number"); + } + }); + + test("listFiles includes package.json", () => { + const repo = Repository.open("."); + const files = repo.listFiles(); + + const packageJson = files.find(f => f.path === "package.json"); + expect(packageJson).toBeDefined(); + expect(packageJson!.path).toBe("package.json"); + }); + }); + + describe("diff", () => { + test("diff returns DiffResult", () => { + const repo = Repository.open("."); + const diff = repo.diff(); + + expect(typeof diff).toBe("object"); + expect(Array.isArray(diff.files)).toBe(true); + expect(typeof diff.stats).toBe("object"); + expect(typeof diff.stats.filesChanged).toBe("number"); + expect(typeof diff.stats.insertions).toBe("number"); + expect(typeof diff.stats.deletions).toBe("number"); + }); + + test("diff with cached option", () => { + const repo = Repository.open("."); + + const workdir = repo.diff({ cached: false }); + const cached = repo.diff({ cached: true }); + + // Both should return valid DiffResult + expect(typeof workdir.stats.filesChanged).toBe("number"); + expect(typeof cached.stats.filesChanged).toBe("number"); + }); + + test("diff files have correct structure", () => { + const repo = Repository.open("."); + const diff = repo.diff(); + + for (const file of diff.files) { + expect(typeof file.status).toBe("number"); + // newPath should always be present + expect(typeof file.newPath).toBe("string"); + // oldPath can be string or null + expect(file.oldPath === null || typeof file.oldPath === "string").toBe(true); + } + }); + + test("DeltaType constants are defined", () => { + expect(DeltaType.UNMODIFIED).toBe(0); + expect(DeltaType.ADDED).toBe(1); + expect(DeltaType.DELETED).toBe(2); + expect(DeltaType.MODIFIED).toBe(3); + expect(DeltaType.RENAMED).toBe(4); + expect(DeltaType.COPIED).toBe(5); + expect(DeltaType.IGNORED).toBe(6); + expect(DeltaType.UNTRACKED).toBe(7); + expect(DeltaType.TYPECHANGE).toBe(8); + expect(DeltaType.CONFLICTED).toBe(10); + }); + }); + + describe("countCommits", () => { + test("countCommits returns a positive number", () => { + const repo = Repository.open("."); + const count = repo.countCommits(); + + expect(typeof count).toBe("number"); + expect(count).toBeGreaterThan(0); + }); + + test("countCommits with range", () => { + const repo = Repository.open("."); + + // Count commits between HEAD~10 and HEAD + const count = repo.countCommits("HEAD~10..HEAD"); + + expect(typeof count).toBe("number"); + expect(count).toBe(10); + }); + }); + + describe("log", () => { + test("log returns an array of Commit objects", () => { + const repo = Repository.open("."); + const commits = repo.log({ limit: 5 }); + + expect(Array.isArray(commits)).toBe(true); + expect(commits.length).toBe(5); + + for (const commit of commits) { + expect(commit).toBeInstanceOf(Commit); + expect(commit.id).toMatch(/^[0-9a-f]{40}$/); + expect(typeof commit.summary).toBe("string"); + } + }); + + test("log with limit option", () => { + const repo = Repository.open("."); + + const ten = repo.log({ limit: 10 }); + const five = repo.log({ limit: 5 }); + + expect(ten.length).toBe(10); + expect(five.length).toBe(5); + }); + + test("log with range option", () => { + const repo = Repository.open("."); + + const commits = repo.log({ range: "HEAD~5..HEAD" }); + + expect(commits.length).toBe(5); + }); + + test("log with from option", () => { + const repo = Repository.open("."); + const head = repo.head(); + + const commits = repo.log({ from: "HEAD", limit: 1 }); + + expect(commits.length).toBe(1); + expect(commits[0].id).toBe(head.id); + }); + + test("log returns commits in chronological order (newest first)", () => { + const repo = Repository.open("."); + const commits = repo.log({ limit: 5 }); + + // Verify commits are sorted by time (newest first) + for (let i = 1; i < commits.length; i++) { + expect(commits[i - 1].time).toBeGreaterThanOrEqual(commits[i].time); + } + }); + }); });