diff --git a/package-lock.json b/package-lock.json index 8da1550..1f7c0e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "node-addon-api": "^8.2.1", "standard": "^10.0.3", "temp": "~0.9.4", + "ts-morph": "^28.0.0", "underscore": "~1.5.2", "wrench": "~1.4.4" } @@ -103,6 +104,53 @@ "node": ">=14" } }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/acorn": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", @@ -331,6 +379,12 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true + }, "node_modules/code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -981,6 +1035,23 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", @@ -1277,9 +1348,9 @@ } }, "node_modules/graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, "node_modules/has": { @@ -1705,6 +1776,12 @@ "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=", "dev": true }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -1929,6 +2006,12 @@ "node": ">=4" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -1986,12 +2069,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "ISC" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/pify": { "version": "3.0.0", @@ -2624,6 +2712,32 @@ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", @@ -2965,6 +3079,43 @@ "dev": true, "optional": true }, + "@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "requires": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + } + } + }, "acorn": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", @@ -3146,6 +3297,12 @@ "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, + "code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -3683,6 +3840,13 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, "figures": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", @@ -3932,9 +4096,9 @@ "dev": true }, "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, "has": { @@ -4266,6 +4430,12 @@ "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=", "dev": true }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -4436,6 +4606,12 @@ "json-parse-better-errors": "^1.0.1" } }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -4474,16 +4650,14 @@ "requires": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - } } }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, "pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -4962,6 +5136,26 @@ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + } + }, + "ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "requires": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", diff --git a/package.json b/package.json index d54603c..d7d4b46 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,13 @@ "vcs" ], "main": "./src/git.js", + "types": "./src/git.d.ts", "devDependencies": { "jasmine": "^5.4.0", "node-addon-api": "^8.2.1", "standard": "^10.0.3", "temp": "~0.9.4", + "ts-morph": "^28.0.0", "underscore": "~1.5.2", "wrench": "~1.4.4" }, diff --git a/spec/api-surface-spec.js b/spec/api-surface-spec.js new file mode 100644 index 0000000..696dece --- /dev/null +++ b/spec/api-surface-spec.js @@ -0,0 +1,35 @@ +const path = require('path') +const { Project } = require('ts-morph') + +// Loading `../src/git` runs its side effects against the `Repository` +// constructor exported by the native addon — adding JS-level wrapper +// methods and deleting `getStatusForPath`. `require` caches modules, so +// requiring the addon afterwards returns the same (already-mutated) +// `Repository` constructor. +require('../src/git') +const { Repository } = require('../build/Release/git.node') + +describe('the Repository API surface', () => { + const project = new Project() + const sourceFile = project.addSourceFileAtPath( + path.join(__dirname, '../src/git.d.ts') + ) + const repositoryClass = sourceFile.getClassOrThrow('Repository') + + const declaredMethods = new Set( + repositoryClass.getInstanceMethods().map(method => method.getName()) + ) + + const runtimeMethods = new Set(Object.getOwnPropertyNames(Repository.prototype)) + runtimeMethods.delete('constructor') + + it('documents every method exposed on Repository.prototype', () => { + const undocumented = [...runtimeMethods].filter(name => !declaredMethods.has(name)) + expect(undocumented).toEqual([]) + }) + + it('does not document methods that no longer exist on Repository.prototype', () => { + const stale = [...declaredMethods].filter(name => !runtimeMethods.has(name)) + expect(stale).toEqual([]) + }) +}) diff --git a/spec/git-spec.js b/spec/git-spec.js index ff37a8f..016a5d3 100644 --- a/spec/git-spec.js +++ b/spec/git-spec.js @@ -7,6 +7,7 @@ const temp = require('temp') const _ = require('underscore') describe('git', () => { + /** @type {import('../src/git').Repository | undefined} */ let repo afterEach(() => repo?.release()) diff --git a/src/git.d.ts b/src/git.d.ts new file mode 100644 index 0000000..195ce77 --- /dev/null +++ b/src/git.d.ts @@ -0,0 +1,560 @@ +/** Metadata for methods that compare two references. */ +export interface AheadBehind { + ahead: number; + behind: number; +} + +/** Metadata for diffs between two references. */ +export interface AddedDeleted { + added: number; + deleted: number; +} + +/** + * Options for {@link Repository#getLineDiffs} and + * {@link Repository#getLineDiffDetails}. + */ +export interface GetLineDiffsOptions { + /** Whether to ignore changes in whitespace at the end of lines. */ + ignoreSpaceAtEOL: boolean; + + /** Alias of {@link GetLineDiffsOptions.ignoreSpaceAtEOL}. */ + ignoreEolWhitespace: boolean; + + /** + * Whether to ignore changes in the amount of whitespace. This ignores + * whitespace at line end (like `ignoreSpaceAtEOL`) and also considers + * all other sequences of one or more whitespace characters to be equivalent. + */ + ignoreSpaceChange: boolean; + + /** + * Whether to ignore whitespace when comparing lines. This goes further than + * `ignoreSpaceChange` — ignoring space differences even if one line has + * whitespace while the other has none. + */ + ignoreAllSpace: boolean; + + /** + * Whether to compare against the index version instead of the HEAD version. + */ + useIndex: boolean; +} + +/** An object returned by {@link Repository#getLineDiffs}. */ +export interface LineDiff { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; +} + +/** An object returned by {@link Repository#getLineDiffDetails}. */ +export interface LineDiffDetail extends LineDiff { + /** + * This line's old line number, or `-1` if the line is not present in the old + * version. + */ + oldLineNumber: number; + + /** + * This line's new line number, or `-1` if the line is not present in the new + * version. + */ + newLineNumber: number; + + /** The content of the line. */ + line: string; +} + +/** + * A single reference as returned by {@link Repository.getReferences}. + */ +export interface Reference { + heads: string[]; + remotes: string[]; + tags: string[]; +} + +declare class Repository { + /** @private */ + constructor(path: string, search?: boolean); + + /** + * Checks whether this repository currently exists. + * + * @returns `true` if the instance still refers to a valid repository; + * `false` if the path on disk no longer exists or is no longer a + * repository. + */ + exists(): boolean; + + /** + * Get the reference or SHA-1 that HEAD points to such as `refs/heads/master` + * or a full SHA-1 if the repository is in a detached HEAD state. + * + * @returns The string reference name or SHA-1. + */ + getHead(): string | null; + + /** + * Get the reference or SHA-1 that HEAD points to such as `refs/heads/master` + * or a full SHA-1 if the repository is in a detached HEAD state. + * + * @returns A promise that resolves with the string reference name or SHA-1. + */ + getHeadAsync(): Promise; + + /** + * Get the number of commits ahead/behind the local branch is compared to the + * remote branch that it tracks. Similar to the commit numbers reported by + * `git status` when a remote tracking branch exists. + * + * @param branch The branch name to look up; defaults to `HEAD`. + * @returns An object with `ahead` and `behind` keys whose values are + * integers that will always be greater than or equal to 0. + */ + getAheadBehindCount(branch?: string): AheadBehind; + + /** + * Get the number of commits ahead/behind the local branch is compared to the + * remote branch that it tracks. Similar to the commit numbers reported by + * `git status` when a remote tracking branch exists. + * + * @param branch The branch name to look up; defaults to `HEAD`. + * @returns A `Promise` returning an object with `ahead` and `behind` keys + * whose values are integers that will always be greater than or equal to 0. + */ + getAheadBehindCountAsync(branch?: string): Promise; + + /** + * Get the status of a single path or all paths in the repository. This will + * not include ignored paths. + * + * @returns An object with paths as keys and integer statuses as values. + */ + getStatus(): Record; + + /** + * Get the status of a single path or all paths in the repository. This will + * not include ignored paths. + * + * @param filePath An optional path (relative to the repository root) if you + * want to retrieve only a single status. + * @returns An integer status. + */ + getStatus(filePath: string): number; + + /** + * Get the status of a single path or all paths in the repository. This will + * not include ignored paths. + * + * @returns A promise that resolves with an object with paths as keys and + * integer statuses as values. + */ + getStatusAsync(): Promise>; + + /** + * Get the status of a single path or all paths in the repository. This will + * not include ignored paths. + * + * @param filePath An optional path (relative to the repository root) if you + * want to retrieve only a single status. + * @returns A promise that resolves with an integer status. + */ + getStatusAsync(filePath: string): Promise; + + /** + * Given a list of paths to files in the repository, returns the statuses of + * each. + * + * @param paths A list of paths (relative to the repository root). + * @returns An object whose keys are paths and whose values are status + * integers. + */ + getStatusForPaths(paths: []): Record; + + /** + * Given a list of paths to files in the repository, returns the statuses of + * each. + * + * @param paths A list of paths (relative to the repository root). + * @returns An promise that resolves with an object whose keys are paths and + * whose values are status integers. + */ + getStatusForPathsAsync(paths: []): Promise>; + + /** + * Release the repository and close all file handles it has open. No other + * methods can be called on the `Repository` object once it has been + * released. + */ + release(): void; + + /** + * Get the working directory of the repository. + */ + getWorkingDirectory(): string | null; + + /** + * Restore the contents of a path in the working directory and index to the + * version at HEAD. Similar to running `git reset HEAD -- ` and then a + * `git checkout HEAD -- `. + * + * @param path The path to checkout; must be relative to the repository root. + * @returns `true` if the checkout was successful; `false` otherwise. + */ + checkoutHead(path: string): boolean; + + /** + * Check out a branch in this repository. + * + * @param reference The reference to check out. + * @param create If `true`, creates the new reference if it doesn't exist; + * defaults to `false`. + * @returns `true` if the checkout was successful; `false` otherwise. + */ + checkoutReference(reference: string, create?: boolean): boolean; + + /** + * @private Prefer {@link checkoutReference}. + */ + checkoutRef(reference: string, create?: boolean): boolean; + + /** + * Get the config value of the given key. + * + * @param key The string key for which to retrieve the value. + * @returns The configuration value if it exists; `null` otherwise. + */ + getConfigValue(key: string): string | null; + + /** + * Set the config value of the given key. + * + * @param key The string key to set in the config. + * @param value The string value to set in the config for the given key. + * @returns `true` if setting the config value was successful; `false` + * otherwise. + */ + setConfigValue(key: string, value: string): boolean; + + /** + * Get the number of lines added and removed comparing the working directory + * contents of the given path to the HEAD version of the given path. + * + * @param path The path to diff; must be relative to the repository root. + * @returns An object with `added` and `deleted` keys whose values are + * integers (>= 0). + */ + getDiffStats(path: string): AddedDeleted; + + /** + * Get the blob contents of the given path at HEAD. + * + * Similar to `git show HEAD:`. + * + * @param path The path; must be relative to the repository root. + * @returns The string contents of the HEAD version of the path. + */ + getHeadBlob(path: string): string; + + /** + * Get the blob contents of the given path in the index. + * + * Similar to `git show :`. + * + * @param path The path; must be relative to the repository root. + * @returns The string contents of the index version of the path. + */ + getIndexBlob(path: string): string; + + /** + * Get the line diffs comparing the HEAD version of the given path to the + * given text. + * + * @param path The path; must be relative to the repository root. + * @param text The string text against which to diff the HEAD contents of the + * path. This will typically be the entire string contents of the file at + * `path`. + * @param options An optional {@link GetLineDiffsOptions} object. + * @returns An array of {@link LineDiff}s, or else `null` if none exist. + */ + getLineDiffs(path: string, text: string, options?: GetLineDiffsOptions): LineDiff[] | null; + + /** + * Get the line diff details comparing the HEAD version of the given path + * to the given text. This returns more metadata than {@link getLineDiffs}. + * + * @param path The path; must be relative to the repository root. + * @param text The string text against which to diff the HEAD contents of the + * path. This will typically be the entire string contents of the file at + * `path`. + * @param options An optional {@link GetLineDiffsOptions} object. + * @returns An array of {@link LineDiffDetail}s, or else `null` if none + * exist. + */ + getLineDiffDetails(path: string, text: string, options?: GetLineDiffsOptions): LineDiffDetail[] | null; + + /** + * Get the path of the repository. + * + * @returns The string absolute path of the opened repository. + */ + getPath(): string; + + /** + * Get all the local and remote references. + * + * @returns A {@link Reference} object. + */ + getReferences(): Reference; + + /** + * Get the target of the given reference. + * + * @param ref The string reference. + * @returns The string target of the given reference. + */ + getReferenceTarget(ref: string): string; + + /** + * Get the branch that a remote's `HEAD` points to — i.e., the remote's + * default branch. This is the equivalent of `git symbolic-ref + * refs/remotes//HEAD`. + * + * @param remoteName The string name of the remote; defaults to `origin`. + * @returns The string reference name — e.g., `refs/remotes/origin/master` — + * or `null` if the remote has no HEAD reference. + */ + getRemoteHead(remoteName?: string): string; + + /** + * Get a possibly shortened version of the value returned by {@link getHead}. + * This will remove leading segments of `refs/heads`, `refs/tags`, or + * `refs/remotes` and will also shorten the SHA-1 of a detached HEAD to 7 + * characters. + * + * @returns A shortened reference name or SHA-1. + */ + getShortHead(): string | null; + + /** + * Get the name of the reference that a symbolic reference points to, without + * resolving it any further. This is the equivalent of `git symbolic-ref + * `. + * + * @param ref The string reference. + * @returns Returns the string name of the reference that `ref` points to; or + * `null` if `ref` does not exist or is not a symbolic reference. + */ + getSymbolicRefTarget(ref: string): string; + + /** + * Get the upstream branch of the given branch. + * + * @param branch The branch to find the upstream branch of; defaults to + * `HEAD`. + * @returns The upstream branch reference name, or `null` if none exists. + */ + getUpstreamBranch(branch?: string): string | null; + + /** + * Get the ignored status of a given path. + * + * @param path The path to the file; must be relative to the repository root. + * @returns `true` if the path is ignored; `false` otherwise. + */ + isIgnored (path: string): boolean; + + /** + * Get the modified status of a given path. + * + * @param path The path to the file; must be relative to the repository root. + * @returns `true` if the path is modified; `false` otherwise. + */ + isPathModified(path: string): boolean; + + /** + * Get the deleted status of a given path. + * + * @param path The path to check; must be relative to the repository root. + * @returns `true` if the path is deleted; `false` otherwise. + */ + isPathDeleted(path: string): boolean; + + /** + * Get the new status of a given path. + * + * @param path The path to the file; must be relative to the repository root. + * @returns `true` if the path is new; `false` otherwise. + */ + isPathNew(path: string): boolean; + + /** + * Get the staged status of a given path. + * + * @param path The path to the file; must be relative to the repository root. + * @returns `true` if the path is staged in the index; `false` otherwise. + */ + isPathStaged(path: string): boolean; + + /** + * Check if a status value represents an ignored path. + * + * @param status The status value. + * @returns `true` if the status is an ignored status; `false` otherwise. + */ + isStatusIgnored(status: number): boolean; + + /** + * Check if a status value represents a new path. + * + * @param status The status value. + * @returns `true` if the status is a new status; `false` otherwise. + */ + isStatusNew(status: number): boolean; + + /** + * Check if a status value represents a deleted path. + * + * @param status The status value. + * @returns `true` if the status is a deleted status; `false` otherwise. + */ + isStatusDeleted(status: number): boolean; + + /** + * Check if a status value represents a changed file that is staged in the + * index. + * + * @param status The status value. + * @returns `true` if the status is a staged status; `false` otherwise. + */ + isStatusStaged(status: number): boolean; + + /** + * Check if a status value represents a modified path. + * + * @param status The status value. + * @returns `true` if the status is a modified status; `false` otherwise. + */ + isStatusModified(status: number): boolean; + + /** + * Check if the path is a submodule in the index. + * + * @param path The path to the file; must be relative to the repository root. + * @returns `true` if the path is a submodule; `false` otherwise. + */ + isSubmodule(path: string): boolean; + + /** + * Reread the index to update any values that have changed since the last + * time the index was read. + */ + refreshIndex(): void; + + /** + * Relativize the given path to the repository's working directory. + * @param path The absolute path to relativize. + * @returns A repository-relative path _if_ the given path descends from this + * repository's root; otherwise returns the given path. + */ + relativize(path: string): boolean; + + /** + * Checks if the given path is the repository's working directory. + * + * It is better to call this method than comparing a path directly against + * the value of {@link getWorkingDirectory} since this method handles slash + * normalization on Windows, case-insensitive filesystems, and symlinked + * repositories. + * + * @param path The path to check. + * @returns `true` if the given path is the repository's working directory; + * `false` otherwise. + */ + isWorkingDirectory(path: string): boolean; + + /** + * Get the repository for the submodule within which the path is located. + * + * @param path The path to the submodule; may be absolute or relative to the + * repository root. + * @returns A {@link Repository}; or `null` if the path isn’t within a + * submodule. + */ + submoduleForPath(path: string): Repository | null; + + /** + * Get the paths of any submodules declared in this repository. + * + * @returns An array of paths that are relative to the repository root. + */ + getSubmodulePaths(): string[]; + + /** + * Stage the changes in `path` into the repository's index. Clear any + * conflict state associated with `path`. + * + * Raises an {@link Error} if the path isn't readable or if another exception + * occurs. + * + * @param path The path to the file whose stages should be changed; must be + * relative to the repository root. + */ + add(path: string): void; + + /** + * Get the number of commits ahead/behind one branch is when compared to the + * other branch. Similar to the commit numbers reported by `git status` when + * a remote tracking branch exists. + * + * @param commitA The first commit SHA-1 to compare. + * @param commitB The second commit SHA-1 to compare. + * @returns An object with `ahead` and `behind` keys whose values are + * integers that will always be greater than or equal to 0. + */ + compareCommits(commitA: string, commitB: string): AheadBehind; + + /** @private */ + compareCommitsAsync(done: (error: Error | null, result: AheadBehind) => void, commitA: string, commitB: string): Promise; + + /** @private */ + workingDirectory: string | null; + + /** @private */ + _getWorkingDirectory(): string | null; + + /** @private */ + _release(): void; + + /** @private */ + submodules: Record; + + /** @internal */ + _lastAsyncPromise?: Promise; + + /** @private */ + caseInsensitiveFs?: boolean; + + /** @private */ + openedWorkingDirectory?: string; +} + +/** + * Open the repository at the given path. This will return `null` if the + * repository at the given path does not exist or cannot be opened. + * + * @param repositoryPath The path from which to try to open a repository. + * @param search Whether to traverse upward from the given path until we find a + * repository. + * @returns A {@link Repository} if one was found; `null` otherwise. + */ +// `Repository` is exported as a type only. The runtime module exports just +// `open`; instances come from it, and the constructor is private. Declaring it +// as an exported class would tell TypeScript there's a runtime value here, +// making `import { Repository }` type-check and then fail in Node ESM. +export type { Repository } + +export function open(repositoryPath: string, search?: boolean): Repository | null diff --git a/src/git.js b/src/git.js index f1bf634..a8aba23 100644 --- a/src/git.js +++ b/src/git.js @@ -1,6 +1,16 @@ +// @ts-check const path = require('path') const fs = require('fs-plus') -const {Repository} = require('../build/Release/git.node') + +/** + * @template T + * @typedef {(err: Error | null, result: T) => void} NodeCallback + */ + +/** @type {{ Repository: typeof import('./git').Repository }} */ +// @ts-expect-error +const bundle = require('../build/Release/git.node') +const { Repository } = bundle const statusIndexNew = 1 << 0 const statusIndexModified = 1 << 1 @@ -355,7 +365,15 @@ Repository.prototype.isWorkingDirectory = function (dirPath) { return false } -const {getHeadAsync, getStatus, getStatusAsync, getStatusForPath} = Repository.prototype +/** + * @typedef {import('./git.js').Repository & { getStatusForPath(path: string): number }} RepositoryProto + */ +const repositoryProto = /** @type {RepositoryProto} */ (Repository.prototype) +const {getHeadAsync, getStatus, getStatusAsync, getStatusForPath} = repositoryProto + +// We don't document `getStatusForPath` because it's used only internally. But +// that means TypeScript gets confused when we try to delete it. +// @ts-expect-error delete Repository.prototype.getStatusForPath Repository.prototype.getStatusForPaths = function (paths) { @@ -386,28 +404,49 @@ Repository.prototype.getStatusForPathsAsync = function (paths) { return performAsyncWork(this, done => getStatusAsync.call(this, done, paths)) } -function performAsyncWork (repo, fn) { - fn = promisify(fn) +/** + * @template T + * @param {import('./git.js').Repository} repo + * @param {(done: NodeCallback) => void} rawFn + * @returns {Promise} + */ +function performAsyncWork (repo, rawFn) { + let fn = promisify(rawFn) - if (repo._lastAsyncPromise) { - repo._lastAsyncPromise = repo._lastAsyncPromise.then(fn, fn) - } else { - repo._lastAsyncPromise = fn() - } - return repo._lastAsyncPromise -} + let result = repo._lastAsyncPromise + ? repo._lastAsyncPromise.then(fn, fn) + : fn() -function promisify (fn) { - return () => new Promise((resolve, reject) => - fn((error, result) => error ? reject(error) : resolve(result)) - ) + repo._lastAsyncPromise = result + return result } -// Given `unrealPath` — which may or may not exist on disk in its current form -// — resolve to a real path on disk, if possible. -// -// This is done by traversing upward to the first directory that _does_ exist, -// then getting its `realpath` and appending the rest back on. +/** + * @template T + * @param {(cb: NodeCallback) => void} fn + * @returns {() => Promise} + */ +function promisify (fn) { + let promisified = () => { + /** @type {Promise} */ + let p = new Promise((resolve, reject) => + fn((error, result) => error ? reject(error) : resolve(result)) + ) + return p + } + return promisified +} + +/** + * Given `unrealPath` — which may or may not exist on disk in its current form + * — resolve to a real path on disk, if possible. + * + * This is done by traversing upward to the first directory that _does_ exist, + * then getting its `realpath` and appending the rest back on. + * + * @param {string} unrealPath + * @returns {string} + */ function realpathRecursive (unrealPath) { let currentPath = unrealPath let result = null @@ -443,13 +482,22 @@ function realpathRecursive (unrealPath) { return toSlashes(trimPath(`${result}/${remainder}`)) } +/** + * Trim a trailing separator from a path. + * @param {string} filePath + * @returns {string} + */ function trimPath (filePath) { if (!filePath.endsWith('/')) return filePath return filePath.replace(/\/$/, '') } -// Attempts to resolve a path to its real path on disk; if it fails, returns -// the original path. +/** + * Attempt to resolve a path to its real path on disk; if it fails, returns the + * original path. + * @param {string} unrealPath + * @returns {string} + */ function realpath (unrealPath) { try { // `fs.realpathSync.native` somehow is the only thing that can consistently @@ -463,21 +511,23 @@ function realpath (unrealPath) { } } -// Returns the parent of `filePath`, or `null` when `filePath` is already a -// root and therefore has no parent. -// -// We ask `path.resolve` whether it can still move upward rather than trying to -// recognize root paths by their shape. Enumerating root *syntaxes* means every -// form we forget is an infinite loop in the callers below, and Windows has -// more of them than the obvious `C:\`: UNC shares (`\\server\share`) and -// device paths (`\\?\C:\`) among them. Resolving to the same path we -// started from is the general signal that we've hit the top, whatever the -// shape. +/** + * Returns the parent of `filePath`, or `null` when `filePath` is already a + * root and therefore has no parent. + * + * @param {string} filePath + * @returns {string | null} + */ function parentPath (filePath) { const parent = path.resolve(filePath, '..') return parent === filePath ? null : parent } +/** + * @param {string} repositoryPath + * @param {boolean} search + * @returns {import('./git.js').Repository | null} + */ function openRepository (repositoryPath, search) { if (!fs.existsSync(repositoryPath)) return null const symlink = realpath(repositoryPath) !== repositoryPath @@ -504,12 +554,18 @@ function openRepository (repositoryPath, search) { } } +/** + * @param {import('./git.js').Repository} repository + * @returns {void} + */ function openSubmodules (repository) { repository.submodules = {} for (let relativePath of repository.getSubmodulePaths()) { if (relativePath) { - const submodulePath = path.join(repository.getWorkingDirectory(), relativePath) + let workingDirectory = repository.getWorkingDirectory() + if (!workingDirectory) throw new Error('Invalid repository') + const submodulePath = path.join(workingDirectory, relativePath) const submoduleRepo = openRepository(submodulePath, false) if (submoduleRepo) { if (submoduleRepo.getPath() === repository.getPath()) { @@ -523,6 +579,7 @@ function openSubmodules (repository) { } } +/** @type {typeof import('./git.js').open} */ exports.open = function (repositoryPath, search = true) { const repository = openRepository(repositoryPath, search) if (repository) openSubmodules(repository)