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 <noreply@anthropic.com>
This commit is contained in:
Sosuke Suzuki
2026-02-04 13:02:28 +09:00
parent d33550ddba
commit c9dc5dd381
4 changed files with 1624 additions and 3 deletions

View File

@@ -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;

View File

@@ -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<JSGitRepository*>(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<int>(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<JSGitRepository*>(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<JSGitRepository*>(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<JSGitRepository*>(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<double>(ahead)));
result->putDirect(vm, JSC::Identifier::fromString(vm, "behind"_s), JSC::jsNumber(static_cast<double>(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<JSGitRepository*>(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<int>(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<double>(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<JSGitRepository*>(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<int>(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<int>(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<double>(filesChanged)));
statsObj->putDirect(vm, JSC::Identifier::fromString(vm, "insertions"_s),
JSC::jsNumber(static_cast<double>(insertions)));
statsObj->putDirect(vm, JSC::Identifier::fromString(vm, "deletions"_s),
JSC::jsNumber(static_cast<double>(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<JSGitRepository*>(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<double>(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<JSGitRepository*>(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<Zig::GlobalObject*>(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<JSC::Strong<JSC::JSObject>> 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<JSC::JSObject>(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<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryHead, 0 } },
{ "path"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryGetPath, 0 } },
{ "workdir"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryGetWorkdir, 0 } },
{ "isBare"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsGitRepositoryIsBare, 0 } },
{ "getStatus"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryGetStatus, 1 } },
{ "revParse"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryRevParse, 1 } },
{ "getCurrentBranch"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryGetCurrentBranch, 0 } },
{ "aheadBehind"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryAheadBehind, 2 } },
{ "listFiles"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryListFiles, 0 } },
{ "diff"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryDiff, 1 } },
{ "countCommits"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryCountCommits, 1 } },
{ "log"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsGitRepositoryLog, 1 } },
};
class JSGitRepositoryPrototype final : public JSC::JSNonFinalObject {

View File

@@ -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,
};

View File

@@ -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);
}
});
});
});