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: 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. diff --git a/spec/git-spec.js b/spec/git-spec.js index 9575a14..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', () => { @@ -1030,6 +1028,7 @@ describe('git', () => { repo.caseInsensitiveFs = true workingDirectory = repo.getWorkingDirectory() + expect(repo.isWorkingDirectory(repoDirectory)).toBe(true) expect(repo.isWorkingDirectory(workingDirectory.toUpperCase())).toBe(true) }) }) @@ -1116,7 +1115,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) 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 }