From aed8bbb884eb51e0287e6226648cd45ad3b78bd2 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Mon, 20 Jan 2025 23:58:27 +0900 Subject: [PATCH 1/2] impl git index --- index.d.ts | 281 +++++++++++++++++++++++-- index.js | 5 +- src/index.rs | 496 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/index.spec.ts | 76 +++++++ 5 files changed, 840 insertions(+), 19 deletions(-) create mode 100644 src/index.rs create mode 100644 tests/index.spec.ts diff --git a/index.d.ts b/index.d.ts index 33cac63..1f2ae74 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,7 +6,101 @@ * napi does not support union types when converting rust enum types to TypeScript. * This feature will be provided starting from v3, so create a custom TypeScript until the v3 stable releases. */ - +export interface IndexEntry { + ctime: Date + mtime: Date + dev: number + ino: number + mode: number + uid: number + gid: number + fileSize: number + id: string + flags: number + flagsExtended: number + /** + * The path of this index entry as a byte vector. Regardless of the + * current platform, the directory separator is an ASCII forward slash + * (`0x2F`). There are no terminating or internal NUL characters, and no + * trailing slashes. Most of the time, paths will be valid utf-8 — but + * not always. For more information on the path storage format, see + * [these git docs][git-index-docs]. Note that libgit2 will take care of + * handling the prefix compression mentioned there. + * + * [git-index-docs]: https://github.com/git/git/blob/a08a83db2bf27f015bec9a435f6d73e223c21c5e/Documentation/technical/index-format.txt#L107-L124 + */ + path: Buffer +} +export interface IndexOnMatchCallbackArgs { + /** The path of entry. */ + path: string + /** The patchspec that matched it. */ + pathspec: string +} +export interface IndexAddAllOptions { + /** + * Files that are ignored will be skipped (unlike `addPath`). If a file is + * already tracked in the index, then it will be updated even if it is + * ignored. Pass the `force` flag to skip the checking of ignore rules. + */ + force?: boolean + /** + * The `pathspecs` are a list of file names or shell glob patterns that + * will matched against files in the repository's working directory. Each + * file that matches will be added to the index (either updating an + * existing entry or adding a new entry). You can disable glob expansion + * and force exact matching with the `disablePathspecMatch` flag. + */ + disablePathspecMatch?: boolean + /** + * To emulate `git add -A` and generate an error if the pathspec contains + * the exact path of an ignored file (when not using `force`), add the + * `checkPathspec` flag. This checks that each entry in `pathspecs` + * that is an exact match to a filename on disk is either not ignored or + * already in the index. If this check fails, the function will return + * an error. + */ + checkPathspec?: boolean + /** + * If you provide a callback function, it will be invoked on each matching + * item in the working directory immediately before it is added to / + * updated in the index. Returning zero will add the item to the index, + * greater than zero will skip the item, and less than zero will abort the + * scan an return an error to the caller. + */ + onMatch?: (args: IndexOnMatchCallbackArgs) => number +} +export type IndexStage = + /** Match any index stage. */ + | 'Any' + /** A normal staged file in the index. (default) */ + | 'Normal' + /** The ancestor side of a conflict. */ + | 'Ancestor' + /** The "ours" side of a conflict. */ + | 'Ours' + /** The "theirs" side of a conflict. */ + | 'Theirs'; +export interface IndexRemoveOptions { + stage?: IndexStage +} +export interface IndexRemoveAllOptions { + /** + * If you provide a callback function, it will be invoked on each matching + * item in the index immediately before it is removed. Return 0 to remove + * the item, > 0 to skip the item, and < 0 to abort the scan. + */ + onMatch?: (args: IndexOnMatchCallbackArgs) => number +} +export interface IndexUpdateAllOptions { + /** + * If you provide a callback function, it will be invoked on each matching + * item in the index immediately before it is updated (either refreshed or + * removed depending on working directory state). Return 0 to proceed with + * updating the item, > 0 to skip the item, and < 0 to abort the scan. + */ + onMatch?: (args: IndexOnMatchCallbackArgs) => number +} /** * Ensure the reference name is well-formed. * @@ -29,7 +123,7 @@ export declare function isReferenceNameValid(refname: string): boolean /** An enumeration of all possible kinds of references. */ export type ReferenceType = - /** A reference which points at an object id. */ +/** A reference which points at an object id. */ | 'Direct' /** A reference which points at another reference. */ | 'Symbolic'; @@ -123,8 +217,8 @@ export interface RenameReferenceOptions { * If the force flag is not enabled, and there's already a reference with * the given name, the renaming will fail. */ - force?: boolean - logMessage?: string + force?: boolean; + logMessage?: string; } export type Direction = | 'Fetch' @@ -136,7 +230,7 @@ export interface RefspecObject { force: boolean; } export type Credential = - /** Create a "default" credential usable for Negotiate mechanisms like NTLM or Kerberos authentication. */ +/** Create a "default" credential usable for Negotiate mechanisms like NTLM or Kerberos authentication. */ | { type: 'Default' } /** * Create a new ssh key credential object used for querying an ssh-agent. @@ -156,16 +250,16 @@ export interface ProxyOptions { * * Note that this will override `url` specified before. */ - auto?: boolean + auto?: boolean; /** * Specify the exact URL of the proxy to use. * * Note that this will override `auto` specified before. */ - url?: string + url?: string; } export type FetchPrune = - /** Use the setting from the configuration */ +/** Use the setting from the configuration */ | 'Unspecified' /** Force pruning on */ | 'On' @@ -173,7 +267,7 @@ export type FetchPrune = | 'Off'; /** Automatic tag following options. */ export type AutotagOption = - /** Use the setting from the remote's configuration */ +/** Use the setting from the remote's configuration */ | 'Unspecified' /** Ask the server for tags pointing to objects we're already downloading */ | 'Auto' @@ -189,7 +283,7 @@ export type AutotagOption = * (`/info/refs`), but not subsequent requests. */ export type RemoteRedirect = - /** Do not follow any off-site redirects at any stage of the fetch or push. */ +/** Do not follow any off-site redirects at any stage of the fetch or push. */ | 'None' /** * Allow off-site redirects only upon the initial request. This is the @@ -335,11 +429,11 @@ export declare function revparseModeContains(source: number, target: number): bo /** A revspec represents a range of revisions within a repository. */ export interface Revspec { /** Access the `from` range of this revspec. */ - from?: string + from?: string; /** Access the `to` range of this revspec. */ - to?: string + to?: string; /** Returns the intent of the revspec. */ - mode: number + mode: number; } /** * A Signature is used to indicate authorship of various actions throughout the @@ -349,17 +443,17 @@ export interface Revspec { */ export interface Signature { /** Name on the signature. */ - name: string + name: string; /** Email on the signature. */ - email: string + email: string; /** Time in seconds, from epoch */ - timestamp: number + timestamp: number; } export interface CreateSignatureOptions { /** Time in seconds, from epoch */ - timestamp: number + timestamp: number; /** Timezone offset, in minutes */ - offset?: number + offset?: number; } /** Create a new action signature. */ export declare function createSignature(name: string, email: string, options?: CreateSignatureOptions | undefined | null): Signature @@ -421,6 +515,145 @@ export declare class Commit { */ time(): Date } +/** + * A structure to represent a git [index][1] + * + * [1]: http://git-scm.com/book/en/Git-Internals-Git-Objects + */ +export declare class Index { + /** + * Get index on-disk version. + * + * Valid return values are 2, 3, or 4. If 3 is returned, an index + * with version 2 may be written instead, if the extension data in + * version 3 is not necessary. + */ + version(): number + /** + * Set index on-disk version. + * + * Valid values are 2, 3, or 4. If 2 is given, git_index_write may + * write an index with version 3 instead, if necessary to accurately + * represent the index. + */ + setVersion(version: number): void + /** Get one of the entries in the index by its path. */ + getByPath(path: string, stage?: IndexStage | undefined | null): IndexEntry | null + /** + * Add or update an index entry from a file on disk + * + * The file path must be relative to the repository's working folder and + * must be readable. + * + * This method will fail in bare index instances. + * + * This forces the file to be added to the index, not looking at gitignore + * rules. + * + * If this file currently is the result of a merge conflict, this file will + * no longer be marked as conflicting. The data about the conflict will be + * moved to the "resolve undo" (REUC) section. + */ + addPath(path: string): void + /** + * Add or update index entries matching files in the working directory. + * + * This method will fail in bare index instances. + * + * The `pathspecs` are a list of file names or shell glob patterns that + * will matched against files in the repository's working directory. Each + * file that matches will be added to the index (either updating an + * existing entry or adding a new entry). + * + * @example + * + * Emulate `git add *`: + * + * ```ts + * import { openRepository } from 'es-git'; + * + * const repo = await openRepository('.'); + * const index = repo.index(); + * index.addAll(['*']); + * index.write(); + * ``` + */ + addAll(pathspecs: Array, options?: IndexAddAllOptions | undefined | null): void + /** + * Update the contents of an existing index object in memory by reading + * from the hard disk. + * + * If force is true, this performs a "hard" read that discards in-memory + * changes and always reloads the on-disk index data. If there is no + * on-disk version, the index will be cleared. + * + * If force is false, this does a "soft" read that reloads the index data + * from disk only if it has changed since the last time it was loaded. + * Purely in-memory index data will be untouched. Be aware: if there are + * changes on disk, unwritten in-memory changes are discarded. + */ + read(force?: boolean | undefined | null): void + /** + * Write the index as a tree. + * + * This method will scan the index and write a representation of its + * current state back to disk; it recursively creates tree objects for each + * of the subtrees stored in the index, but only returns the OID of the + * root tree. This is the OID that can be used e.g. to create a commit. + * + * The index instance cannot be bare, and needs to be associated to an + * existing repository. + * + * The index must not contain any file in conflict. + */ + writeTree(): string + /** + * Remove an index entry corresponding to a file on disk. + * + * The file path must be relative to the repository's working folder. It + * may exist. + * + * If this file currently is the result of a merge conflict, this file will + * no longer be marked as conflicting. The data about the conflict will be + * moved to the "resolve undo" (REUC) section. + */ + removePath(path: string, options?: IndexRemoveOptions | undefined | null): void + /** Remove all matching index entries. */ + removeAll(pathspecs: Array, options?: IndexRemoveAllOptions | undefined | null): void + /** + * Update all index entries to match the working directory + * + * This method will fail in bare index instances. + * + * This scans the existing index entries and synchronizes them with the + * working directory, deleting them if the corresponding working directory + * file no longer exists otherwise updating the information (including + * adding the latest version of file to the ODB if needed). + */ + updateAll(pathspecs: Array, options?: IndexUpdateAllOptions | undefined | null): void + /** Get the count of entries currently in the index */ + count(): number + /** Return `true` is there is no entry in the index */ + isEmpty(): boolean + /** + * Get the full path to the index file on disk. + * + * Returns `None` if this is an in-memory index. + */ + path(): string | null + /** + * Does this index have conflicts? + * + * Returns `true` if the index contains conflicts, `false` if it does not. + */ + hasConflicts(): boolean + /** Get an iterator over the entries in this index. */ + entries(): IndexEntries +} +/** An iterator over the entries in an index */ +export declare class IndexEntries { + [Symbol.iterator](): Iterator +} /** * A structure representing a [remote][1] of a git repository. * @@ -489,6 +722,18 @@ export declare class Repository { findCommit(oid: string): Commit | null /** Lookup a reference to one of the commits in a repository. */ getCommit(oid: string): Commit + /** + * Get the Index file for this repository. + * + * If a custom index has not been set, the default index for the repository + * will be returned (the one located in .git/index). + * + * **Caution**: If the [`git2::Repository`] of this index is dropped, then this + * [`git2::Index`] will become detached, and most methods on it will fail. See + * [`git2::Index::open`]. Be sure the repository has a binding such as a local + * variable to keep it alive at least as long as the index. + */ + index(): Index /** * Lookup a reference to one of the objects in a repository. * diff --git a/index.js b/index.js index 5008db8..57057a0 100644 --- a/index.js +++ b/index.js @@ -310,9 +310,12 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { Commit, ObjectType, GitObject, ReferenceType, Reference, isReferenceNameValid, ReferenceFormat, normalizeReferenceName, Direction, CredentialType, FetchPrune, AutotagOption, RemoteRedirect, Remote, RepositoryState, RepositoryOpenFlags, Repository, initRepository, openRepository, discoverRepository, cloneRepository, RevparseMode, revparseModeContains, RevwalkSort, Revwalk, createSignature } = nativeBinding +const { Commit, IndexStage, Index, IndexEntries, ObjectType, GitObject, ReferenceType, Reference, isReferenceNameValid, ReferenceFormat, normalizeReferenceName, Direction, CredentialType, FetchPrune, AutotagOption, RemoteRedirect, Remote, RepositoryState, RepositoryOpenFlags, Repository, initRepository, openRepository, discoverRepository, cloneRepository, RevparseMode, revparseModeContains, RevwalkSort, Revwalk, createSignature } = nativeBinding module.exports.Commit = Commit +module.exports.IndexStage = IndexStage +module.exports.Index = Index +module.exports.IndexEntries = IndexEntries module.exports.ObjectType = ObjectType module.exports.GitObject = GitObject module.exports.ReferenceType = ReferenceType diff --git a/src/index.rs b/src/index.rs new file mode 100644 index 0000000..220be58 --- /dev/null +++ b/src/index.rs @@ -0,0 +1,496 @@ +use crate::repository::Repository; +use crate::util; +use chrono::{DateTime, Timelike, Utc}; +use napi::bindgen_prelude::*; +use napi::JsString; +use napi_derive::napi; +use std::path::Path; + +#[napi(object)] +pub struct IndexEntry { + pub ctime: DateTime, + pub mtime: DateTime, + pub dev: u32, + pub ino: u32, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub file_size: u32, + pub id: String, + pub flags: u16, + pub flags_extended: u16, + /// The path of this index entry as a byte vector. Regardless of the + /// current platform, the directory separator is an ASCII forward slash + /// (`0x2F`). There are no terminating or internal NUL characters, and no + /// trailing slashes. Most of the time, paths will be valid utf-8 — but + /// not always. For more information on the path storage format, see + /// [these git docs][git-index-docs]. Note that libgit2 will take care of + /// handling the prefix compression mentioned there. + /// + /// [git-index-docs]: https://github.com/git/git/blob/a08a83db2bf27f015bec9a435f6d73e223c21c5e/Documentation/technical/index-format.txt#L107-L124 + pub path: Buffer, +} + +impl TryFrom for IndexEntry { + type Error = crate::Error; + + fn try_from(value: git2::IndexEntry) -> std::result::Result { + let ctime = DateTime::::from_timestamp(value.ctime.seconds() as i64, value.ctime.nanoseconds()) + .ok_or(crate::Error::InvalidTime)?; + let mtime = DateTime::::from_timestamp(value.mtime.seconds() as i64, value.mtime.nanoseconds()) + .ok_or(crate::Error::InvalidTime)?; + Ok(Self { + ctime, + mtime, + dev: value.dev, + ino: value.ino, + mode: value.mode, + uid: value.uid, + gid: value.gid, + file_size: value.file_size, + id: value.id.to_string(), + flags: value.flags, + flags_extended: value.flags_extended, + path: Buffer::from(value.path), + }) + } +} + +impl TryFrom for git2::IndexEntry { + type Error = crate::Error; + + fn try_from(value: IndexEntry) -> std::result::Result { + Ok(git2::IndexEntry { + ctime: git2::IndexTime::new(value.ctime.second() as i32, value.ctime.nanosecond()), + mtime: git2::IndexTime::new(value.mtime.second() as i32, value.mtime.nanosecond()), + dev: value.dev, + ino: value.ino, + mode: value.mode, + uid: value.uid, + gid: value.gid, + file_size: value.file_size, + id: git2::Oid::from_str(&value.id)?, + flags: value.flags, + flags_extended: value.flags_extended, + path: value.path.to_vec(), + }) + } +} + +#[napi(object)] +pub struct IndexOnMatchCallbackArgs { + /// The path of entry. + pub path: String, + /// The patchspec that matched it. + pub pathspec: String, +} + +#[napi(object)] +pub struct IndexAddAllOptions { + /// Files that are ignored will be skipped (unlike `addPath`). If a file is + /// already tracked in the index, then it will be updated even if it is + /// ignored. Pass the `force` flag to skip the checking of ignore rules. + pub force: Option, + /// The `pathspecs` are a list of file names or shell glob patterns that + /// will matched against files in the repository's working directory. Each + /// file that matches will be added to the index (either updating an + /// existing entry or adding a new entry). You can disable glob expansion + /// and force exact matching with the `disablePathspecMatch` flag. + pub disable_pathspec_match: Option, + /// To emulate `git add -A` and generate an error if the pathspec contains + /// the exact path of an ignored file (when not using `force`), add the + /// `checkPathspec` flag. This checks that each entry in `pathspecs` + /// that is an exact match to a filename on disk is either not ignored or + /// already in the index. If this check fails, the function will return + /// an error. + pub check_pathspec: Option, + #[napi(ts_type = "(args: IndexOnMatchCallbackArgs) => number")] + /// If you provide a callback function, it will be invoked on each matching + /// item in the working directory immediately before it is added to / + /// updated in the index. Returning zero will add the item to the index, + /// greater than zero will skip the item, and less than zero will abort the + /// scan an return an error to the caller. + pub on_match: Option>, +} + +impl IndexAddAllOptions { + pub fn get_flag(&self) -> git2::IndexAddOption { + let mut flag = git2::IndexAddOption::DEFAULT; + if let Some(true) = self.force { + flag |= git2::IndexAddOption::FORCE; + } + if let Some(true) = self.disable_pathspec_match { + flag |= git2::IndexAddOption::DISABLE_PATHSPEC_MATCH; + } + if let Some(true) = self.check_pathspec { + flag |= git2::IndexAddOption::CHECK_PATHSPEC; + } + flag + } +} + +#[napi(string_enum)] +pub enum IndexStage { + /// Match any index stage. + Any, + /// A normal staged file in the index. + Normal, + /// The ancestor side of a conflict. + Ancestor, + /// The "ours" side of a conflict. + Ours, + /// The "theirs" side of a conflict. + Theirs, +} + +impl Default for IndexStage { + fn default() -> Self { + Self::Normal + } +} + +impl From for i32 { + fn from(value: IndexStage) -> Self { + match value { + IndexStage::Any => -1, + IndexStage::Normal => 0, + IndexStage::Ancestor => 1, + IndexStage::Ours => 2, + IndexStage::Theirs => 3, + } + } +} + +#[napi(object)] +pub struct IndexRemoveOptions { + pub stage: Option, +} + +#[napi(object)] +pub struct IndexRemoveAllOptions { + #[napi(ts_type = "(args: IndexOnMatchCallbackArgs) => number")] + /// If you provide a callback function, it will be invoked on each matching + /// item in the index immediately before it is removed. Return 0 to remove + /// the item, > 0 to skip the item, and < 0 to abort the scan. + pub on_match: Option>, +} + +#[napi(object)] +pub struct IndexUpdateAllOptions { + #[napi(ts_type = "(args: IndexOnMatchCallbackArgs) => number")] + /// If you provide a callback function, it will be invoked on each matching + /// item in the index immediately before it is updated (either refreshed or + /// removed depending on working directory state). Return 0 to proceed with + /// updating the item, > 0 to skip the item, and < 0 to abort the scan. + pub on_match: Option>, +} + +#[napi] +/// A structure to represent a git [index][1] +/// +/// [1]: http://git-scm.com/book/en/Git-Internals-Git-Objects +pub struct Index { + pub(crate) inner: git2::Index, +} + +#[napi] +impl Index { + #[napi] + /// Get index on-disk version. + /// + /// Valid return values are 2, 3, or 4. If 3 is returned, an index + /// with version 2 may be written instead, if the extension data in + /// version 3 is not necessary. + pub fn version(&self) -> u32 { + self.inner.version() + } + + #[napi] + /// Set index on-disk version. + /// + /// Valid values are 2, 3, or 4. If 2 is given, git_index_write may + /// write an index with version 3 instead, if necessary to accurately + /// represent the index. + pub fn set_version(&mut self, version: u32) -> crate::Result<()> { + self.inner.set_version(version)?; + Ok(()) + } + + #[napi] + /// Get one of the entries in the index by its path. + pub fn get_by_path(&self, path: String, stage: Option) -> Option { + self + .inner + .get_path(Path::new(&path), stage.unwrap_or_default().into()) + .and_then(|x| IndexEntry::try_from(x).ok()) + } + + #[napi] + /// Add or update an index entry from a file on disk + /// + /// The file path must be relative to the repository's working folder and + /// must be readable. + /// + /// This method will fail in bare index instances. + /// + /// This forces the file to be added to the index, not looking at gitignore + /// rules. + /// + /// If this file currently is the result of a merge conflict, this file will + /// no longer be marked as conflicting. The data about the conflict will be + /// moved to the "resolve undo" (REUC) section. + pub fn add_path(&mut self, path: String) -> crate::Result<()> { + self.inner.add_path(Path::new(&path))?; + Ok(()) + } + + #[napi] + /// Add or update index entries matching files in the working directory. + /// + /// This method will fail in bare index instances. + /// + /// The `pathspecs` are a list of file names or shell glob patterns that + /// will matched against files in the repository's working directory. Each + /// file that matches will be added to the index (either updating an + /// existing entry or adding a new entry). + /// + /// @example + /// + /// Emulate `git add *`: + /// + /// ```ts + /// import { openRepository } from 'es-git'; + /// + /// const repo = await openRepository('.'); + /// const index = repo.index(); + /// index.addAll(['*']); + /// index.write(); + /// ``` + pub fn add_all( + &mut self, + env: Env, + pathspecs: Vec, + options: Option, + ) -> crate::Result<()> { + let (flag, callback) = match options { + Some(opts) => { + let flag = opts.get_flag(); + let callback = opts.on_match.and_then(|js_cb| js_cb.create_ref().ok()).map(|js_ref| { + move |args: IndexOnMatchCallbackArgs| { + js_ref + .borrow_back(&env) + .and_then(|callback| callback.call(args)) + .unwrap() + } + }); + (flag, callback) + } + None => (git2::IndexAddOption::DEFAULT, None), + }; + if let Some(cb) = callback { + let mut git2_cb = move |path: &Path, data: &[u8]| -> i32 { + let path = path.to_string_lossy().to_string(); + let pathspec = std::str::from_utf8(data).unwrap().to_string(); + cb(IndexOnMatchCallbackArgs { path, pathspec }) + }; + self.inner.add_all(&pathspecs, flag, Some(&mut git2_cb))?; + } else { + self.inner.add_all(&pathspecs, flag, None)?; + } + Ok(()) + } + + #[napi] + /// Update the contents of an existing index object in memory by reading + /// from the hard disk. + /// + /// If force is true, this performs a "hard" read that discards in-memory + /// changes and always reloads the on-disk index data. If there is no + /// on-disk version, the index will be cleared. + /// + /// If force is false, this does a "soft" read that reloads the index data + /// from disk only if it has changed since the last time it was loaded. + /// Purely in-memory index data will be untouched. Be aware: if there are + /// changes on disk, unwritten in-memory changes are discarded. + pub fn read(&mut self, force: Option) -> crate::Result<()> { + self.inner.read(force.unwrap_or_default())?; + Ok(()) + } + + #[napi] + /// Write the index as a tree. + /// + /// This method will scan the index and write a representation of its + /// current state back to disk; it recursively creates tree objects for each + /// of the subtrees stored in the index, but only returns the OID of the + /// root tree. This is the OID that can be used e.g. to create a commit. + /// + /// The index instance cannot be bare, and needs to be associated to an + /// existing repository. + /// + /// The index must not contain any file in conflict. + pub fn write_tree(&mut self) -> crate::Result { + let id = self.inner.write_tree().map(|x| x.to_string())?; + Ok(id) + } + + #[napi] + /// Remove an index entry corresponding to a file on disk. + /// + /// The file path must be relative to the repository's working folder. It + /// may exist. + /// + /// If this file currently is the result of a merge conflict, this file will + /// no longer be marked as conflicting. The data about the conflict will be + /// moved to the "resolve undo" (REUC) section. + pub fn remove_path(&mut self, path: String, options: Option) -> crate::Result<()> { + if let Some(IndexRemoveOptions { stage: Some(stage) }) = options { + self.inner.remove(Path::new(&path), i32::from(stage))?; + } else { + self.inner.remove_path(Path::new(&path))?; + } + Ok(()) + } + + #[napi] + /// Remove all matching index entries. + pub fn remove_all( + &mut self, + env: Env, + pathspecs: Vec, + options: Option, + ) -> crate::Result<()> { + let callback = options + .and_then(|x| x.on_match) + .and_then(|x| x.create_ref().ok()) + .map(|js_ref| { + move |args: IndexOnMatchCallbackArgs| { + js_ref + .borrow_back(&env) + .and_then(|callback| callback.call(args)) + .unwrap() + } + }); + if let Some(cb) = callback { + let mut git2_cb = move |path: &Path, data: &[u8]| -> i32 { + let path = path.to_string_lossy().to_string(); + let pathspec = std::str::from_utf8(data).unwrap().to_string(); + cb(IndexOnMatchCallbackArgs { path, pathspec }) + }; + self.inner.remove_all(&pathspecs, Some(&mut git2_cb))?; + } else { + self.inner.remove_all(&pathspecs, None)?; + } + Ok(()) + } + + #[napi] + /// Update all index entries to match the working directory + /// + /// This method will fail in bare index instances. + /// + /// This scans the existing index entries and synchronizes them with the + /// working directory, deleting them if the corresponding working directory + /// file no longer exists otherwise updating the information (including + /// adding the latest version of file to the ODB if needed). + pub fn update_all( + &mut self, + env: Env, + pathspecs: Vec, + options: Option, + ) -> crate::Result<()> { + let callback = options + .and_then(|x| x.on_match) + .and_then(|x| x.create_ref().ok()) + .map(|js_ref| { + move |args: IndexOnMatchCallbackArgs| { + js_ref + .borrow_back(&env) + .and_then(|callback| callback.call(args)) + .unwrap() + } + }); + if let Some(cb) = callback { + let mut git2_cb = move |path: &Path, data: &[u8]| -> i32 { + let path = path.to_string_lossy().to_string(); + let pathspec = std::str::from_utf8(data).unwrap().to_string(); + cb(IndexOnMatchCallbackArgs { path, pathspec }) + }; + self.inner.update_all(&pathspecs, Some(&mut git2_cb))?; + } else { + self.inner.update_all(&pathspecs, None)?; + } + Ok(()) + } + + #[napi] + /// Get the count of entries currently in the index + pub fn count(&self) -> u32 { + self.inner.len() as u32 + } + + #[napi] + /// Return `true` is there is no entry in the index + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + #[napi] + /// Get the full path to the index file on disk. + /// + /// Returns `None` if this is an in-memory index. + pub fn path(&self, env: Env) -> Option { + self.inner.path().and_then(|x| util::path_to_js_string(&env, x).ok()) + } + + #[napi] + /// Does this index have conflicts? + /// + /// Returns `true` if the index contains conflicts, `false` if it does not. + pub fn has_conflicts(&self) -> bool { + self.inner.has_conflicts() + } + + #[napi] + /// Get an iterator over the entries in this index. + pub fn entries(&self, this: Reference, env: Env) -> crate::Result { + let inner = this.share_with(env, |index| Ok(index.inner.iter()))?; + Ok(IndexEntries { inner }) + } +} + +#[napi(iterator)] +/// An iterator over the entries in an index +pub struct IndexEntries { + pub(crate) inner: SharedReference>, +} + +#[napi] +impl Generator for IndexEntries { + type Yield = IndexEntry; + type Next = (); + type Return = (); + + fn next(&mut self, _value: Option) -> Option { + self.inner.next().and_then(|x| IndexEntry::try_from(x).ok()) + } +} + +#[napi] +impl Repository { + #[napi] + /// Get the Index file for this repository. + /// + /// If a custom index has not been set, the default index for the repository + /// will be returned (the one located in .git/index). + /// + /// **Caution**: If the [`git2::Repository`] of this index is dropped, then this + /// [`git2::Index`] will become detached, and most methods on it will fail. See + /// [`git2::Index::open`]. Be sure the repository has a binding such as a local + /// variable to keep it alive at least as long as the index. + pub fn index(&self) -> crate::Result { + Ok(Index { + inner: self.inner.index()?, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index e323e4c..5880813 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod commit; mod error; +pub mod index; pub mod object; pub mod reference; pub mod remote; diff --git a/tests/index.spec.ts b/tests/index.spec.ts new file mode 100644 index 0000000..3aa669c --- /dev/null +++ b/tests/index.spec.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { type IndexEntry, openRepository } from '../index'; +import { useFixture } from './fixtures'; + +function entryPath(entry: IndexEntry): string { + return entry.path.toString('utf8'); +} + +describe('index', () => { + it('can get index of repository', async () => { + const p = await useFixture('commits'); + const repo = await openRepository(p); + const index = repo.index(); + expect(index.count()).toBe(2); + expect(index.isEmpty()).toBe(false); + expect(index.path()).toMatch(path.join(p, '.git', 'index')); + }); + + it('get entries of index', async () => { + const p = await useFixture('commits'); + const repo = await openRepository(p); + const index = repo.index(); + expect([...index.entries()].map(entryPath)).toEqual(['first', 'second']); + }); + + it('add with path', async () => { + const p = await useFixture('empty'); + const repo = await openRepository(p); + const index = repo.index(); + await fs.writeFile(path.join(p, 'A'), 'A'); + index.addPath('A'); + expect([...index.entries()].map(entryPath)).toContain('A'); + }); + + it('add all with pathspecs', async () => { + const p = await useFixture('empty'); + const repo = await openRepository(p); + const index = repo.index(); + await fs.writeFile(path.join(p, 'A'), 'A'); + await fs.writeFile(path.join(p, 'B'), 'B'); + const onMatch = vi.fn().mockReturnValue(0); + index.addAll(['*'], { onMatch }); + const entryPaths = [...index.entries()].map(entryPath); + expect(entryPaths).toContain('A'); + expect(entryPaths).toEqual(expect.arrayContaining(['A', 'B'])); + expect(index.count()).toBe(2); + expect(onMatch).toHaveBeenCalledTimes(2); + const calls = onMatch.mock.calls; + expect(calls).toEqual(expect.arrayContaining([[{ path: 'A', pathspec: '*' }], [{ path: 'B', pathspec: '*' }]])); + }); + + it('remove all with pathspecs', async () => { + const p = await useFixture('empty'); + const repo = await openRepository(p); + const index = repo.index(); + await fs.writeFile(path.join(p, 'A'), 'A'); + index.addAll(['*']); + expect(index.count()).toBe(1); + index.removeAll(['A']); + expect(index.count()).toBe(0); + }); + + it('update all with pathspecs', async () => { + const p = await useFixture('empty'); + const repo = await openRepository(p); + const index = repo.index(); + await fs.writeFile(path.join(p, 'A'), 'A'); + index.addAll(['*']); + expect(index.getByPath('A')?.fileSize).toBe(1); + await fs.writeFile(path.join(p, 'A'), 'AA'); + index.updateAll(['*']); + expect(index.getByPath('A')?.fileSize).toBe(2); + }); +}); From 61b5c7b4082900462a1da190ad12632d519e1d75 Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Tue, 21 Jan 2025 22:08:11 +0900 Subject: [PATCH 2/2] fix test --- tests/index.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 3aa669c..918fa42 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -15,7 +15,9 @@ describe('index', () => { const index = repo.index(); expect(index.count()).toBe(2); expect(index.isEmpty()).toBe(false); - expect(index.path()).toMatch(path.join(p, '.git', 'index')); + // Regardless of the current platform, the directory separator is an ASCII forward slash(`/`), + // so we have to match ends of index path. + expect(index.path()).toMatch('.git/index'); }); it('get entries of index', async () => {