From 859ee9645d4b0e8e0da2d39d2cc216da58fb8c7b Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Fri, 12 Jun 2026 23:21:22 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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: