mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
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:
452
packages/bun-types/git.d.ts
vendored
452
packages/bun-types/git.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user