From 859ee9645d4b0e8e0da2d39d2cc216da58fb8c7b Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Fri, 12 Jun 2026 23:21:22 -0700 Subject: [PATCH 1/6] Fix case sensitivity and realpath issues on Windows --- spec/git-spec.js | 14 ++- src/git.js | 239 +++++++++++++++++++++++++++++++---------------- 2 files changed, 173 insertions(+), 80 deletions(-) diff --git a/spec/git-spec.js b/spec/git-spec.js index 9575a14..8582ff6 100644 --- a/spec/git-spec.js +++ b/spec/git-spec.js @@ -982,10 +982,15 @@ describe('git', () => { const repoDirectory = fs.realpathSync(temp.mkdirSync('node-git-repo-')) const linkDirectory = path.join(fs.realpathSync(temp.mkdirSync('node-git-repo-')), 'link') wrench.copyDirSyncRecursive(path.join(__dirname, 'fixtures/master.git'), path.join(repoDirectory, '.git')) + console.log('SYMLINKING', repoDirectory, 'TO', linkDirectory); fs.symlinkSync(repoDirectory, linkDirectory) + console.log('!!! so the realPath of linkDirectory:', linkDirectory, 'should be', fs.realpathSync(linkDirectory)); + repo = git.open(linkDirectory) + console.log('relativizing:', path.join(repoDirectory, 'test1')); expect(repo.relativize(path.join(repoDirectory, 'test1'))).toBe('test1') + console.log('DOES THIS PATH EXIST?!?', fs.existsSync(path.join(linkDirectory, 'test2'))); expect(repo.relativize(path.join(linkDirectory, 'test2'))).toBe('test2') expect(repo.relativize(path.join(linkDirectory, 'test2/test3'))).toBe('test2/test3') expect(repo.relativize('test2/test3')).toBe('test2/test3') @@ -1030,7 +1035,14 @@ describe('git', () => { repo.caseInsensitiveFs = true workingDirectory = repo.getWorkingDirectory() - expect(repo.isWorkingDirectory(workingDirectory.toUpperCase())).toBe(true) + console.log('Comparing repo directory:', repoDirectory, 'to working directory:', workingDirectory); + + console.log('Just a HUNCH:', fs.realpathSync.native(repoDirectory), 'from', repoDirectory); + // if (process.platform === 'win32') { + expect(repo.isWorkingDirectory(repoDirectory)).toBe(true) + expect(repo.isWorkingDirectory(workingDirectory.toUpperCase())).toBe(true) + // } + }) }) diff --git a/src/git.js b/src/git.js index 361810b..6373ab3 100644 --- a/src/git.js +++ b/src/git.js @@ -32,6 +32,77 @@ const indexStatusFlags = statusIndexRenamed | statusIndexTypeChange + +const IS_WINDOWS = process.platform === 'win32' + +// Given a path on disk (real or hypothetical), attempt to normalize it by +// (optionally) resolving `realpath` and (if on Windows) converting all path +// separators to forward slashes. +function normalizePath (filePath, useRealpath = true) { + if (typeof filePath !== 'string') return filePath + + if (useRealpath) { + filePath = realpath(filePath) + } + if (!IS_WINDOWS) return filePath + return realpath(filePath).replace(/\\/g, '/') +} + +// Compare two paths to determine whether they resolve to the same file or +// directory on disk. +// +// This is more complicated than it sounds — not just because of symlinks but +// also because of files/directories on Windows possibly having both a short +// name and a long name. +function pathsAreEqual (pathA, pathB, caseInsensitive = false, useRealpath = true) { + if (typeof pathA !== 'string' || typeof pathB !== 'string') { + return false + } + + pathA = normalizePath(pathA, useRealpath) + pathB = normalizePath(pathB, useRealpath) + + if (IS_WINDOWS || caseInsensitive) { + pathA = pathA.toLowerCase() + pathB = pathB.toLowerCase() + } + + let result = pathA === pathB + if (result || !IS_WINDOWS) return result + if (!pathA.includes('~') && !pathB.includes('~')) { + return result + } + + // If we get this far, we're on Windows and comparing two paths, at least one + // of which contains an 8.3 short name. The only obvious and reliable way to + // address this is to `statSync` both paths and verify their IDs are the + // same. + if (!fs.existsSync(pathA) || !fs.existsSync(pathB)) { + return result + } + let statA = fs.statSync(pathA) + let statB = fs.statSync(pathB) + + return statA.ino === statB.ino && statA.dev === statB.dev +} + +// Returns whether `pathA` starts with `pathB` — i.e., whether `pathB` is equal +// to `pathA` or else one of its ancestor directories. +function pathStartsWith (pathA, pathB, caseInsensitive = false, useRealpath = true) { + if (IS_WINDOWS) { + pathA = normalizePath(pathA, useRealpath) + pathB = normalizePath(pathB, useRealpath) + } + if (caseInsensitive) { + pathA = pathA.toLowerCase() + pathB = pathB.toLowerCase() + } + if (!pathB.endsWith(`/`)) { + pathB = `${pathB}/` + } + return pathA.startsWith(pathB) +} + Repository.prototype.release = function () { for (let submodulePath in this.submodules) { const submoduleRepo = this.submodules[submodulePath] @@ -130,7 +201,7 @@ Repository.prototype.getAheadBehindCount = function (branch = 'HEAD') { return this.compareCommits(headCommit, upstreamCommit) } -Repository.prototype.getAheadBehindCountAsync = function (branch = 'HEAD') { +Repository.prototype.getAheadBehindCountAsync = async function (branch = 'HEAD') { if (branch !== 'HEAD' && !branch.startsWith('refs/heads/')) { branch = `refs/heads/${branch}` } @@ -159,89 +230,66 @@ Repository.prototype.checkoutReference = function (branch, create) { Repository.prototype.relativize = function (filePath) { let workingDirectory if (!filePath) return filePath + filePath = realpathRecursive(filePath) - if (process.platform === 'win32') { - filePath = filePath.replace(/\\/g, '/') - } else { - if (filePath[0] !== '/') return filePath + if (!IS_WINDOWS && filePath[0] !== '/') { + return filePath } - if (this.caseInsensitiveFs) { - const lowerCasePath = filePath.toLowerCase() - - workingDirectory = this.getWorkingDirectory() - if (workingDirectory) { - workingDirectory = workingDirectory.toLowerCase() - if (lowerCasePath.startsWith(`${workingDirectory}/`)) { - return filePath.substring(workingDirectory.length + 1) - } else if (lowerCasePath === workingDirectory) { - return '' - } - } - - if (this.openedWorkingDirectory) { - workingDirectory = this.openedWorkingDirectory.toLowerCase() - if (lowerCasePath.startsWith(`${workingDirectory}/`)) { - return filePath.substring(workingDirectory.length + 1) - } else if (lowerCasePath === workingDirectory) { - return '' - } - } - } else { - workingDirectory = this.getWorkingDirectory() - if (workingDirectory) { - if (filePath.startsWith(`${workingDirectory}/`)) { - return filePath.substring(workingDirectory.length + 1) - } else if (filePath === workingDirectory) { - return '' - } + workingDirectory = this.getWorkingDirectory() + if (workingDirectory) { + if (pathStartsWith(filePath, workingDirectory, this.caseInsensitiveFs, false)) { + return filePath.substring(workingDirectory.length + 1) + } else if (pathsAreEqual(filePath, workingDirectory, this.caseInsensitiveFs, false)) { + return '' } + } - if (this.openedWorkingDirectory) { - if (filePath.startsWith(`${this.openedWorkingDirectory}/`)) { - return filePath.substring(this.openedWorkingDirectory.length + 1) - } else if (filePath === this.openedWorkingDirectory) { - return '' - } + if (this.openedWorkingDirectory) { + workingDirectory = this.openedWorkingDirectory + if (pathStartsWith(filePath, workingDirectory, this.caseInsensitiveFs, false)) { + return filePath.substring(workingDirectory.length + 1) + } else if (pathsAreEqual(filePath, workingDirectory, this.caseInsensitiveFs, false)) { + return '' } } return filePath } -Repository.prototype.submoduleForPath = function (path) { - path = this.relativize(path) - if (!path) return null +Repository.prototype.submoduleForPath = function (filePath) { + filePath = this.relativize(filePath) + if (!filePath) return null for (let submodulePath in this.submodules) { const submoduleRepo = this.submodules[submodulePath] - if (path === submodulePath) { + if (filePath === submodulePath) { return submoduleRepo - } else if (path.startsWith(`${submodulePath}/`)) { - path = path.substring(submodulePath.length + 1) - return submoduleRepo.submoduleForPath(path) || submoduleRepo + } else if (filePath.startsWith(`${submodulePath}/`)) { + filePath = filePath.substring(submodulePath.length + 1) + return submoduleRepo.submoduleForPath(filePath) || submoduleRepo } } return null } -Repository.prototype.isWorkingDirectory = function (path) { - if (!path) return false +Repository.prototype.isWorkingDirectory = function (dirPath) { + if (!dirPath) return false + dirPath = normalizePath(dirPath) - if (process.platform === 'win32') { - path = path.replace(/\\/g, '/') - } else { - if (path[0] !== '/') return false + if (!IS_WINDOWS && dirPath[0] !== '/') { + return false } - if (this.caseInsensitiveFs) { - const lowerCasePath = path.toLowerCase() - const workingDirectory = this.getWorkingDirectory() - if (workingDirectory && workingDirectory.toLowerCase() === lowerCasePath) return true - if (this.openedWorkingDirectory && this.openedWorkingDirectory.toLowerCase() === lowerCasePath) return true - } else { - return path === this.getWorkingDirectory() || path === this.openedWorkingDirectory + let workingDirectory = this.getWorkingDirectory() + if (workingDirectory && pathsAreEqual(workingDirectory, dirPath, this.caseInsensitiveFs)) { + return true + } + + let openedWorkingDirectory = this.openedWorkingDirectory + if (openedWorkingDirectory && pathsAreEqual(openedWorkingDirectory, dirPath, this.caseInsensitiveFs)) { + return true } return false @@ -258,9 +306,9 @@ Repository.prototype.getStatusForPaths = function (paths) { } } -Repository.prototype.getStatus = function (path) { - if (typeof path === 'string') { - return getStatusForPath.call(this, path) +Repository.prototype.getStatus = function (filePath) { + if (typeof filePath === 'string') { + return getStatusForPath.call(this, filePath) } else { return getStatus.call(this) } @@ -295,16 +343,61 @@ function promisify (fn) { ) } +// 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. +function realpathRecursive (unrealPath) { + let currentPath = unrealPath + let result = unrealPath + let remainder = '' + if (!path.isAbsolute(unrealPath)) { + return realpath(unrealPath, true) + } + while (!isRootPath(currentPath)) { + try { + result = fs.realpathSync.native(currentPath) + break + } catch (e) { + if (e.message.includes('ENOENT')) { + currentPath = path.resolve(currentPath, '..') + remainder = path.relative(currentPath, unrealPath) + } else { + return unrealPath + } + } + } + if (isRootPath(currentPath)) { + return unrealPath + } + let finalResult = trimPath(`${result}/${remainder}`) + return normalizePath(finalResult) +} + +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. function realpath (unrealPath) { try { + // `fs.realpathSync.native` somehow is the only thing that can consistently + // normalize 8.3 "short names" in Windows to their long equivalents. + if (typeof fs.realpathSync.native === 'function') { + return fs.realpathSync.native(unrealPath) + } return fs.realpathSync(unrealPath) } catch (e) { return unrealPath } } +// Returns whether the path has no parent directory. function isRootPath (repositoryPath) { - if (process.platform === 'win32') { + if (IS_WINDOWS) { return /^[a-zA-Z]+:[\\/]$/.test(repositoryPath) } else { return repositoryPath === path.sep @@ -312,29 +405,17 @@ function isRootPath (repositoryPath) { } function openRepository (repositoryPath, search) { + if (!fs.existsSync(repositoryPath)) return null const symlink = realpath(repositoryPath) !== repositoryPath + repositoryPath = normalizePath(repositoryPath, false) - if (process.platform === 'win32') { - repositoryPath = repositoryPath.replace(/\\/g, '/') - } const repository = new Repository(repositoryPath, search) if (repository.exists()) { repository.caseInsensitiveFs = fs.isCaseInsensitive() if (symlink) { const workingDirectory = repository.getWorkingDirectory() - // On Windows, normalize both sides through realpath so that 8.3 short - // names (e.g., RUNNER~1) and path separator differences don't prevent - // the comparison from matching. Compare case-insensitively because - // Windows paths are case-insensitive. - const normalizedWorkingDir = process.platform === 'win32' - ? realpath(workingDirectory).replace(/\\/g, '/').toLowerCase() - : workingDirectory while (!isRootPath(repositoryPath)) { - let realpathResult = realpath(repositoryPath) - if (process.platform === 'win32') { - realpathResult = realpathResult.replace(/\\/g, '/').toLowerCase() - } - if (realpathResult === normalizedWorkingDir) { + if (pathsAreEqual(repositoryPath, workingDirectory, fs.isCaseInsensitive())) { repository.openedWorkingDirectory = repositoryPath break } From d7a2d509dc3c2a918d2933b31a4cf7a0d7a89709 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 13 Jun 2026 10:45:14 -0700 Subject: [PATCH 2/6] Clean up spec --- spec/git-spec.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/spec/git-spec.js b/spec/git-spec.js index 8582ff6..50a60d2 100644 --- a/spec/git-spec.js +++ b/spec/git-spec.js @@ -1035,14 +1035,8 @@ describe('git', () => { repo.caseInsensitiveFs = true workingDirectory = repo.getWorkingDirectory() - console.log('Comparing repo directory:', repoDirectory, 'to working directory:', workingDirectory); - - console.log('Just a HUNCH:', fs.realpathSync.native(repoDirectory), 'from', repoDirectory); - // if (process.platform === 'win32') { - expect(repo.isWorkingDirectory(repoDirectory)).toBe(true) - expect(repo.isWorkingDirectory(workingDirectory.toUpperCase())).toBe(true) - // } - + expect(repo.isWorkingDirectory(repoDirectory)).toBe(true) + expect(repo.isWorkingDirectory(workingDirectory.toUpperCase())).toBe(true) }) }) @@ -1128,7 +1122,9 @@ describe('git', () => { expect(repo.submoduleForPath('sub1')).toBe(null) let submoduleRepoPath = path.join(repo.getPath(), 'modules', 'sub/') - if (process.platform === 'win32') { submoduleRepoPath = submoduleRepoPath.replace(/\\/g, '/') } + if (process.platform === 'win32') { + submoduleRepoPath = submoduleRepoPath.replace(/\\/g, '/') + } expect(repo.submoduleForPath('sub').getPath()).toBe(submoduleRepoPath) expect(repo.submoduleForPath('sub/').getPath()).toBe(submoduleRepoPath) From abf772ae8ed577a160bfbdcb822d248fece68276 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 13 Jun 2026 14:13:58 -0700 Subject: [PATCH 3/6] Clean up spec (still) --- spec/git-spec.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/spec/git-spec.js b/spec/git-spec.js index 50a60d2..e1b6fda 100644 --- a/spec/git-spec.js +++ b/spec/git-spec.js @@ -9,9 +9,7 @@ const _ = require('underscore') describe('git', () => { let repo - afterEach(() => { - if (repo) repo.release() - }) + afterEach(() => repo?.release()) describe('.open(path)', () => { describe('when the path is a repository', () => { @@ -982,15 +980,10 @@ describe('git', () => { const repoDirectory = fs.realpathSync(temp.mkdirSync('node-git-repo-')) const linkDirectory = path.join(fs.realpathSync(temp.mkdirSync('node-git-repo-')), 'link') wrench.copyDirSyncRecursive(path.join(__dirname, 'fixtures/master.git'), path.join(repoDirectory, '.git')) - console.log('SYMLINKING', repoDirectory, 'TO', linkDirectory); fs.symlinkSync(repoDirectory, linkDirectory) - console.log('!!! so the realPath of linkDirectory:', linkDirectory, 'should be', fs.realpathSync(linkDirectory)); - repo = git.open(linkDirectory) - console.log('relativizing:', path.join(repoDirectory, 'test1')); expect(repo.relativize(path.join(repoDirectory, 'test1'))).toBe('test1') - console.log('DOES THIS PATH EXIST?!?', fs.existsSync(path.join(linkDirectory, 'test2'))); expect(repo.relativize(path.join(linkDirectory, 'test2'))).toBe('test2') expect(repo.relativize(path.join(linkDirectory, 'test2/test3'))).toBe('test2/test3') expect(repo.relativize('test2/test3')).toBe('test2/test3') From 4bd096af754347129f3eb99b82f658d1164f9341 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 13 Jun 2026 14:14:29 -0700 Subject: [PATCH 4/6] Remove nonexistent methods from README --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index b99ab1a..de1f5d9 100644 --- a/README.md +++ b/README.md @@ -71,16 +71,6 @@ remote tracking branch exists. Returns an object with `ahead` and `behind` keys pointing to integer values that will always be >= 0. -### Repository.getCommitCount(fromCommit, toCommit) - -Get the number of commits between `fromCommit` and `toCommit`. - -`fromCommit` - The string commit SHA-1 to start the rev walk at. - -`toCommit` - The string commit SHA-1 to end the rev walk at. - -Returns the number of commits between the two, always >= 0. - ### Repository.getConfigValue(key) Get the config value of the given key. @@ -170,17 +160,6 @@ object has `oldStart`, `oldLines`, `newStart`, `newLines`, `oldLineNumber` and `newLineNumber` keys pointing to integer values, and a `line` key pointing to the respective line content. May be `null` if the diff fails. -### Repository.getMergeBase(commit1, commit2) - -Get the merge base of two commits. - -`commit1` - The string SHA-1 of the first commit. - -`commit2` - The string SHA-1 of the second commit. - -Returns the string SHA-1 of the merge base of `commit1` and `commit2` or `null` -if there isn't one. - ### Repository.getPath() Get the path of the repository. From 259a70327e7882a72eb733068134d1cd58868c0b Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 13 Jun 2026 14:25:27 -0700 Subject: [PATCH 5/6] Pin to `windows-2022` instead of `windows-latest` --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ba9db6..0b1a2ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: CC: clang CXX: clang++ npm_config_clang: 1 - - os: windows-latest + - os: windows-2022 env: {} - os: macos-latest env: From 51b9198cfc44c0792e2aa93f36ee8e438c139f64 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sun, 14 Jun 2026 15:14:14 -0700 Subject: [PATCH 6/6] =?UTF-8?q?Add=20a=20`.d.ts`=20file=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and some tests to ensure the `.d.ts` document everything in the `Repository` interface (and nothing that isn't there). --- package-lock.json | 232 ++++++++++++++-- package.json | 2 + spec/api-surface-spec.js | 35 +++ spec/git-spec.js | 1 + src/git.d.ts | 554 +++++++++++++++++++++++++++++++++++++++ src/git.js | 114 ++++++-- 6 files changed, 894 insertions(+), 44 deletions(-) create mode 100644 spec/api-surface-spec.js create mode 100644 src/git.d.ts 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 e1b6fda..aba6412 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..cb759e7 --- /dev/null +++ b/src/git.d.ts @@ -0,0 +1,554 @@ +/** 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[]; +} + +export 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. + */ +export function open(repositoryPath: string, search?: boolean): Repository | null diff --git a/src/git.js b/src/git.js index 6373ab3..019f9ba 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 @@ -295,7 +305,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) { @@ -326,34 +344,55 @@ 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 = unrealPath let remainder = '' if (!path.isAbsolute(unrealPath)) { - return realpath(unrealPath, true) + return realpath(unrealPath) } while (!isRootPath(currentPath)) { try { @@ -375,13 +414,22 @@ function realpathRecursive (unrealPath) { return normalizePath(finalResult) } +/** + * 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 @@ -395,7 +443,11 @@ function realpath (unrealPath) { } } -// Returns whether the path has no parent directory. +/** + * Check whether the given path is the root — i.e., has no parent directory. + * @param {string} repositoryPath + * @returns {boolean} + */ function isRootPath (repositoryPath) { if (IS_WINDOWS) { return /^[a-zA-Z]+:[\\/]$/.test(repositoryPath) @@ -404,6 +456,11 @@ function isRootPath (repositoryPath) { } } +/** + * @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 @@ -428,12 +485,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()) { @@ -447,6 +510,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)