From 406d93a1648a88d2fdce662bec2658c1298f8372 Mon Sep 17 00:00:00 2001 From: Todd Hogarth Date: Fri, 29 May 2026 08:47:15 -0400 Subject: [PATCH 1/4] Convert repo IO and processing to async/await Replace blocking fs/path calls with node:fs/promises and node:path and convert package retrieval, repo discovery, and git reflog processing to async/await to avoid synchronous I/O and improve robustness and error propagation. Improve error construction to preserve the original error as the cause. Refactor git output parsing to filter earlier and collect per-repo errors without failing the whole run. Update tests to use async patterns and the platform path separator. Changed files: - src/common/repos.js - src/grefplus/cmdline.js - src/grefplus/index.js - test/repos.spec.js --- src/common/repos.js | 70 +++++++++++---------- src/grefplus/cmdline.js | 6 +- src/grefplus/index.js | 134 +++++++++++++++++++--------------------- test/repos.spec.js | 47 +++++++------- 4 files changed, 130 insertions(+), 127 deletions(-) diff --git a/src/common/repos.js b/src/common/repos.js index 5d2c27d..ee2389f 100644 --- a/src/common/repos.js +++ b/src/common/repos.js @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ -const { readFileSync, readdirSync } = require('fs'); -const { basename, join } = require('path'); +const { readFile, readdir } = require('node:fs/promises'); +const { basename, join } = require('node:path'); const { fileExists, folderExists } = require('./files'); require('dotenv').config(); @@ -11,12 +11,12 @@ const { DEVROOT } = process.env; /** * Obtains the package.json file from repo path * - * @param {String} pkgFile file - path/package.json - * @returns {Object} JSON object + * @param {string} pkgFile file - path/package.json + * @returns {Promise} JSON object */ -const getPackage = (pkgFile) => { +const getPackage = async (pkgFile) => { if(fileExists(pkgFile)) { - const data = readFileSync(pkgFile).toString(); + const data = (await readFile(pkgFile)).toString(); return JSON.parse(data); } return { error: true }; @@ -25,42 +25,46 @@ const getPackage = (pkgFile) => { /** * Determine if a folder contains a .git folder * - does not check that git can process the repo so we can still get false positives - * @param {String} path - * @return {Boolean} + * @param {string} path + * @return {boolean} */ const isGitRepo = (path) => (basename(path) === '.git') && folderExists(path); /** * Obtains paths of all git repositories * - only search down one folder - * @param {String} folder - * @param {Array} foldersToInclude - * @return {Array} path strings + * @param {string} folder + * @param {string[]} foldersToInclude - limit the folders to return to those in this list. If empty, return all repos + * @return {Promise} path strings */ -const allRepoPaths = (folder = DEVROOT, foldersToInclude = []) => { +const allRepoPaths = async (folder = DEVROOT, foldersToInclude = []) => { // get files in root - return readdirSync(folder) - .filter(name => { - if(foldersToInclude.length) { - return foldersToInclude.includes(name); - } - return true; - }) - .map(name => join(folder, name)) - .filter(folderExists) - .filter(path => { - // returns array of path that end a folder named .git - const result = readdirSync(path) - .reduce((accumulator, current) => { - const subFolder = join(path, current); - // this is not fool-proof ideally we want to skip bogus repos - if(isGitRepo(subFolder)) { - accumulator.push(subFolder); - } - return accumulator; - }, [])[0]; - return result !== undefined; + + const initialNames = await readdir(folder); + const filtered = initialNames.reduce((acc, name) => { + if(foldersToInclude.length && !foldersToInclude.includes(name)) { + return acc; + } + const path = join(folder, name); + if(folderExists(path)) { + acc.push(path); + } + return acc; + }, []); + + const result = []; + for(const path of filtered) { + const items = await readdir(path); + const hasGitRepo = items.some(current => { + const subFolder = join(path, current); + return isGitRepo(subFolder); }); + if(hasGitRepo) { + result.push(path); + } + } + + return result; }; diff --git a/src/grefplus/cmdline.js b/src/grefplus/cmdline.js index c155855..ca41e33 100644 --- a/src/grefplus/cmdline.js +++ b/src/grefplus/cmdline.js @@ -59,9 +59,9 @@ const validateDate = (checkDate, msg) => { return true; } catch (e) { - const error = new Error(); - error.message = `${msg} not recognized as a date [${checkDate}]. Valid formats are: ${options.allowedFormat}`; - error.cause = e.message; + const message = `${msg} not recognized as a date [${checkDate}]. Valid formats are: ${options.allowedFormat}`; + const error = new Error(message); + error.cause = e; throw error; } }; diff --git a/src/grefplus/index.js b/src/grefplus/index.js index c0f5d08..2576f7f 100644 --- a/src/grefplus/index.js +++ b/src/grefplus/index.js @@ -1,9 +1,9 @@ /* eslint no-console:off */ require('./cmdline').setOptions(); -const { basename } = require('path'); +const { basename } = require('node:path'); const { allRepoPaths } = require('../common/repos'); -const { promisify } = require('util'); -const _exec = promisify(require('child_process').exec); +const { promisify } = require('node:util'); +const _exec = promisify(require('node:child_process').exec); const { DateTime } = require('luxon'); const { options } = require('./cmdline'); const DateLength = 6; @@ -22,10 +22,10 @@ const gitCommand = (repo) => { /** * determines if item falls within range * - * @param {Object} item + * @param {object} item * @param {DateTime | undefined} item.fromDate * @param {DateTime | undefined} item.toDate - * @returns {Boolean} + * @returns {boolean} * @private * */ @@ -48,38 +48,39 @@ const filterPeriod = (item) => { /** * Obtains the git reflogs result - * @param {String} repo - full path to a repository + * @param {string} repo - full path to a repository * @param {Array} errors - place to store skippable errors - * @return {Array} objects containing date, body, and the repository base name + * @return {Promise<{date: DateTime, body: string, repo: string}[]>} objects containing date, body, and the repository base name */ -const processRepo = (repo, errors) => { - return new Promise((resolve) => { +async function processRepo(repo, errors) { + try { const cmd = gitCommand(repo); - _exec(cmd, { encoding:'utf8' }) - .then(out => out.stdout.trim()) - .then(stdout => { - const results = stdout.split(' +++') - .filter(item => { - return item.trim().length > 0; - }) - .map(item => { - return item.trim(); - }) - .map(item => { - const date = DateTime.fromFormat(item.substring(DateLength, item.search(/[=]{2}/)), options.dateOptions); - const body = item.substring(item.search(/[=]{2}/) + options.offset); - return { date, body, repo: basename(repo) }; - }) - .filter(filterPeriod); - return resolve(results); - }) - .catch(err => { - errors.push({ repo: repo, error: err }); - // continue to next repo but be sure to return empty array - return resolve([]); - }); - }); -}; + const { stdout } = await _exec(cmd, { encoding:'utf8' }); + const lines = []; + const repoName = basename(repo); + for(const item of stdout.trim().split(' +++')) { + const _item = item.trim(); + if(_item.length === 0) { + continue; + } + + const markerIndex = _item.indexOf('=='); + const date = DateTime.fromFormat(_item.substring(DateLength, markerIndex), options.dateOptions); + if(!filterPeriod({ date })) { + continue; + } + + const body = _item.substring(markerIndex + options.offset); + lines.push({ date, body, repo: repoName }); + } + return lines; + } + catch (err) { + errors.push({ repo: basename(repo), error: err ? err.message : 'Unknown error' }); + // continue to next repo but be sure to return empty array + return []; + } +} /** * writes errors to console if in debug mode @@ -102,43 +103,38 @@ const logErrors = (errors, isDebug, err) => { /** * Entry point */ -const main = () => { - if(options.devRoot.length) { - const errors = []; - let maxRepoLength = 0; - const repos = []; - options.devRoot.forEach(root => { - allRepoPaths(root, options.folderNames).forEach(repo => { - repos.push(repo); - }); - }); - const promises = repos.map(repo => processRepo(repo, errors)); +async function main() { + if(options.devRoot.length === 0) { + console.log(`bash variable DEVROOT is required`); + process.exitCode = 1; + return; + } - return Promise - .all(promises) - .then(results => { - results - .reduce((acc, item) => acc.concat(item), []) - .sort((a, b) => a.date.valueOf() - b.date.valueOf()) - .map(item => { - if(item.repo.length > maxRepoLength) { - maxRepoLength = item.repo.length; - } - return item; - }) - .map(item => { - let name = item.repo; - name = name.padEnd(maxRepoLength); - console.log(`${item.date.toFormat(options.dateOptions)} ${name} ${item.body}`); - }); - }) - .catch(err => { - logErrors(errors, options.debug, err); - }); + const errors = []; + let maxRepoLength = 0; + const repos = []; + for(const root of options.devRoot) { + const paths = await allRepoPaths(root, options.folderNames); + repos.push(...paths); } + const promises = repos.map(repo => processRepo(repo, errors)); - console.log(`bash variable DEVROOT is required`); - process.exitCode = 1; -}; + try { + const result = await Promise.all(promises); + const sorted = result + .flat() + .sort((a, b) => a.date.valueOf() - b.date.valueOf()); + sorted.forEach(item => { + maxRepoLength = Math.max(maxRepoLength, item.repo.length); + }); + sorted.forEach(item => { + console.log(`${item.date.toFormat(options.dateOptions)} ${item.repo.padEnd(maxRepoLength)} ${item.body}`); + }); + } + catch (err) { + logErrors(errors, options.debug, err); + } + +} main().catch(console.error); diff --git a/test/repos.spec.js b/test/repos.spec.js index cd7c27f..ef69492 100644 --- a/test/repos.spec.js +++ b/test/repos.spec.js @@ -4,12 +4,11 @@ const { expect } = chai; chai.use(require('sinon-chai')); const mockFS = require('mock-fs'); -const { join } = require('path'); +const { join, sep } = require('node:path'); const { allRepoPaths , getBinaryPaths , getPackage } = require('../src/common/repos'); -const { sep } = require('path'); const fakePackage = { 'name': 'faker' @@ -49,51 +48,55 @@ const fake = { }; // @TODO: mock out fileExists and folderExists -describe('Repositories Modules', () => { - describe('allRepPaths', () => { +describe('Repositories Modules', function () { + describe('allRepPaths', function () { before(() => { mockFS(fake); }); after(mockFS.restore); - it('should find git repos', () => { - const result = allRepoPaths('./root'); + it('should find git repos', async function() { + const result = await allRepoPaths('./root'); expect(result).an('array').of.length(2); expect(result).contains(`root${sep}folder1`); expect(result).contains(`root${sep}folder2`); }); - it('should find only named git repos', () => { - const result = allRepoPaths('./root', [ 'folder1' ]); + it('should find only named git repos', async function() { + const result = await allRepoPaths('./root', [ 'folder1' ]); expect(result).an('array').of.length(1); expect(result).contains(`root${sep}folder1`); }); - it('should not find any git repos when named is not a git repo', () => { - const result = allRepoPaths('./root', [ 'folder3' ]); + it('should not find any git repos when named is not a git repo', async function() { + const result = await allRepoPaths('./root', [ 'folder3' ]); expect(result).an('array').of.length(0); }); - it('should not find any git repos when root does not contain repos in immediate sub folders', () => { - const result = allRepoPaths('.'); + it('should not find any git repos when root does not contain repos in immediate sub folders', async function() { + const result = await allRepoPaths('.'); expect(result).an('array').of.length(0); }); }); - describe('getPackage()', () => { - it('should return empty object on failure', () => { + describe('getPackage()', function () { + it('should return empty object on failure', async function () { mockFS({ repo: { } }); - const { name, error } = getPackage(join('repo', 'package.json')); + const { name, error } = await getPackage(join('repo', 'package.json')); expect(name).to.be.undefined; expect(error).to.be.true; mockFS.restore(); }); - it('should return parsed JSON on success', () => { - mockFS({ repo: { 'package.json': JSON.stringify({ name: 'hello' }) } }); + it('should return parsed JSON on success', async function () { + const repo = { + 'package.json': JSON.stringify({ name: 'hello' }) + }; + mockFS({ repo }); - const { name, error } = getPackage(join('repo', 'package.json')); + const file = join('repo', 'package.json'); + const { name, error } = await getPackage(file); expect(name).equals('hello'); expect(error).to.be.undefined; @@ -101,14 +104,14 @@ describe('Repositories Modules', () => { mockFS.restore(); }); }); - describe('getBinaryPaths()', () => { - it('should be empty object on join fails', () => { + describe('getBinaryPaths()', function () { + it('should be empty object on join fails', function () { const { buildRoot, gulpFile } = getBinaryPaths(); expect(buildRoot).to.be.undefined; expect(gulpFile).to.be.undefined; }); - it('buildRoot should be within tooling', () => { + it('buildRoot should be within tooling', function () { const { buildRoot, gulpFile } = getBinaryPaths('myBuilder', 'myRepo', 'myRoot'); expect(buildRoot).matches(/^myRoot/); @@ -116,7 +119,7 @@ describe('Repositories Modules', () => { expect(gulpFile).to.match(/myBuilder/); expect(gulpFile).to.match(/gulp\.js$/); }); - it('buildRoot should be in calling repo', () => { + it('buildRoot should be in calling repo', function () { const { buildRoot, gulpFile } = getBinaryPaths('', 'myRepo', 'myRoot'); expect(buildRoot).matches(/^myRepo/); From 636c47c3c883a12c25ab3a916bd60cce59e8c8a2 Mon Sep 17 00:00:00 2001 From: Todd Hogarth Date: Fri, 29 May 2026 09:00:06 -0400 Subject: [PATCH 2/4] Refactor repositoryEngines to use async/await and update tests for error handling --- src/common/engine.js | 26 ++++++------- test/engine.spec.js | 87 ++++++++++++++++++++++++++++++-------------- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/common/engine.js b/src/common/engine.js index f140fc5..745feb9 100644 --- a/src/common/engine.js +++ b/src/common/engine.js @@ -137,7 +137,7 @@ const versionStringToNumber = (version) => { // eslint-disable-next-line no-magic-numbers _expanded += i === 0 ? s : s.padStart(NumbersPadding, '0'); }); - return parseInt(_expanded, 10); + return Number.parseInt(_expanded, 10); }; /** @@ -153,7 +153,7 @@ const versionStringToObject = (v, versions, oldest = false) => { const matchingVersions = versions.filter(({ version }) => rx.test(version)); // versions array is sorted in version descending order if(oldest) { - return matchingVersions[matchingVersions.length - 1]; + return matchingVersions.at(-1); } return matchingVersions[0]; }; @@ -248,23 +248,23 @@ const maxInstalledSatisfyingVersion = (requiredRange) => */ const minInstalledSatisfyingVersion = (requiredRange) => { const version = module.exports.satisfyingVersions(requiredRange); - return version[version.length - 1]; + return version.at(-1); }; /** * Obtains node engine range * * @param {String} repoPath - to repository - * @returns {String} engines | default engine + * @returns {Promise} engines | default engine * @throws RangeError */ -const repositoryEngines = (repoPath) => { +const repositoryEngines = async (repoPath) => { const file = repoPath.endsWith('package.json') ? repoPath : resolve(join(repoPath, 'package.json')); - const { error, engines } = getPackage(file); + const { error, engines } = await getPackage(file); if(error) { throw new RangeError(`package file not found in ${repoPath}`); } - if(engines && engines.node) { + if(engines?.node) { return engines.node; } return defaultVersion; @@ -277,11 +277,11 @@ const repositoryEngines = (repoPath) => { * @param {String=} param0.version - version number x.y.z * @param {Boolean=} param0.oldest - choose oldest acceptable version * @param {Boolean=} noPackage - path does not have package.json - * @returns {Version} + * @returns {Promise} version object with path to executables * @throws RangeError */ -const versionToUseValidator = ({ path, version, oldest }, noPackage) => { - const repoEngines = noPackage ? version : module.exports.repositoryEngines(path); +const versionToUseValidator = async ({ path, version, oldest }, noPackage) => { + const repoEngines = noPackage ? version : await module.exports.repositoryEngines(path); const repoName = basename(path); if(version) { @@ -292,9 +292,9 @@ const versionToUseValidator = ({ path, version, oldest }, noPackage) => { oldest ); // _version is undefined if version is not in satisfies - const found = satisfies.filter( - (v) => _version && v.version === _version.version - )[0]; + const found = satisfies.find( + (v) => v.version === _version?.version + ); if(!found) { throw new RangeError( `${repoName} requires NodeJS version(s) '${repoEngines}' but got '${version}'` diff --git a/test/engine.spec.js b/test/engine.spec.js index e76ece8..ea728b5 100644 --- a/test/engine.spec.js +++ b/test/engine.spec.js @@ -432,44 +432,77 @@ describe('Engine Module', function() { }); describe('repositoryEngines()', function() { afterEach(mockFS.restore); - it('should throw RangeError when package not found', function() { - const wrapper = () => engine.repositoryEngines('missing'); + it('should reject with RangeError when package not found', async function() { + const getPackage = sinon.stub().resolves({ error: new Error('missing') }); + const { repositoryEngines } = proxyquire('../src/common/engine', { + './repos.js': { getPackage } + }); + + try { + await repositoryEngines('missing'); + expect.fail('expected repositoryEngines() to reject'); + } + catch (error) { + expect(error).to.be.instanceOf(RangeError); + expect(error.message).to.equal('package file not found in missing'); + } - expect(wrapper).to.throw(RangeError); + expect(getPackage).to.have.been.calledOnce; }); - it('should not throw', function() { - const content = { engines:{ node: '8.11.1' } }; - mockFS({ - myRepo: { - 'package.json': JSON.stringify(content) - } + + it('should return node engine when engines.node exists', async function() { + const getPackage = sinon.stub().resolves({ + engines: { node: '8.11.1' } + }); + const { repositoryEngines } = proxyquire('../src/common/engine', { + './repos.js': { getPackage } }); - let result; - const wrapper = function() { - result = engine.repositoryEngines('myRepo'); - }; + const result = await repositoryEngines('myRepo'); - expect(wrapper).not.to.throw(); expect(result).to.equal('8.11.1'); + expect(getPackage).to.have.been.calledOnce; }); - it('should return default there is not a node property', function() { - const content = { engines:{ yarn: '^1.22.4' } }; - mockFS({ - myRepo: { - 'package.json': JSON.stringify(content) - } + + it('should return default when there is not a node property', async function() { + const getPackage = sinon.stub().resolves({ + engines: { yarn: '^1.22.4' } + }); + const { repositoryEngines } = proxyquire('../src/common/engine', { + './repos.js': { getPackage } + }); + + const result = await repositoryEngines('myRepo'); + + expect(result).to.equal('12.13.1'); + expect(getPackage).to.have.been.calledOnce; + }); + + it('should return default when engines is missing', async function() { + const getPackage = sinon.stub().resolves({}); + const { repositoryEngines } = proxyquire('../src/common/engine', { + './repos.js': { getPackage } }); - let result; - expect(result).to.be.undefined; - const wrapper = function() { - result = engine.repositoryEngines('myRepo'); - }; + const result = await repositoryEngines('myRepo'); + + expect(result).to.equal('12.13.1'); + expect(getPackage).to.have.been.calledOnce; + }); + + it('should use package.json path as-is', async function() { + const getPackage = sinon.stub().resolves({ + engines: { node: '^18.0.0' } + }); + const { repositoryEngines } = proxyquire('../src/common/engine', { + './repos.js': { getPackage } + }); + const packagePath = `myRepo${sep}package.json`; - expect(wrapper).not.to.throw(); - expect(result).not.to.be.undefined; + const result = await repositoryEngines(packagePath); + expect(result).to.equal('^18.0.0'); + expect(getPackage).to.have.been.calledOnceWithExactly(packagePath); }); }); describe('versionToUseValidator()', function() { From 4b72a844128fd218ff43663b0ca3d29a3fe05cc5 Mon Sep 17 00:00:00 2001 From: Todd Hogarth Date: Fri, 29 May 2026 09:22:16 -0400 Subject: [PATCH 3/4] Make version validation async and update tests Convert CLI yargs checks to await the asynchronous version validator so repository engine checks can perform async I/O. Update unit tests to use async/await and adjust stubs to return/resolves values; add tests covering oldest/max/min selection logic. Remove slow/flaky git integration tests. Update the temp test to use a real temp base. Also bump dependency versions for simple-git and uuid. Changed files: - package.json - src/spawner/index.js - src/yarn/index.js - test/engine.spec.js - test/temp.spec.js - test/git-seq.spec.js (deleted) - test/git.spec.js (deleted) --- package.json | 4 +- src/spawner/index.js | 4 +- src/yarn/index.js | 4 +- test/engine.spec.js | 105 +++++++++++++++++++++++++++-------- test/git-seq.spec.js | 78 -------------------------- test/git.spec.js | 127 ------------------------------------------- test/temp.spec.js | 11 +--- yarn.lock | 38 +++++++++---- 8 files changed, 119 insertions(+), 252 deletions(-) delete mode 100644 test/git-seq.spec.js delete mode 100644 test/git.spec.js diff --git a/package.json b/package.json index 6542321..1db5853 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "luxon": "^3.7.2", "semver": "^7.7.3", "shelljs": "^0.10.0", - "simple-git": "^3.32.3", - "uuid": "11.1.0", + "simple-git": "^3.36.0", + "uuid": "11.1.1", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/src/spawner/index.js b/src/spawner/index.js index e252f98..f084856 100644 --- a/src/spawner/index.js +++ b/src/spawner/index.js @@ -89,9 +89,9 @@ yargs } return true; }) - .check(({ version, path, oldest }) => { + .check(async ({ version, path, oldest }) => { const hasPackage = path.endsWith('package.json') || fileExists(join(path, 'package.json')); - versionToUse = versionToUseValidator({ path, version, oldest }, !hasPackage); + versionToUse = await versionToUseValidator({ path, version, oldest }, !hasPackage); return Boolean(versionToUse); }); }, diff --git a/src/yarn/index.js b/src/yarn/index.js index 451a0c8..c1a9ce2 100644 --- a/src/yarn/index.js +++ b/src/yarn/index.js @@ -70,8 +70,8 @@ yargs } return true; }) - .check(({ version, path, oldest }) => { - versionToUse = versionToUseValidator({ path, version, oldest }); + .check(async ({ version, path, oldest }) => { + versionToUse = await versionToUseValidator({ path, version, oldest }); return Boolean(versionToUse); }); }, diff --git a/test/engine.spec.js b/test/engine.spec.js index ea728b5..c8bd259 100644 --- a/test/engine.spec.js +++ b/test/engine.spec.js @@ -3,8 +3,8 @@ const { expect } = chai; const sinon = require('sinon'); chai.use(require('sinon-chai')); const mockFS = require('mock-fs'); -const { sep } = require('path'); -const { platform } = require('os'); +const { sep } = require('node:path'); +const { platform } = require('node:os'); const isWindows = platform() === 'win32'; const proxyquire = require('proxyquire').noCallThru(); @@ -506,7 +506,7 @@ describe('Engine Module', function() { }); }); describe('versionToUseValidator()', function() { - let satisfyingVersions; let maxInstalledSatisfyingVersion;let versionStringToObject; + let satisfyingVersions; let maxInstalledSatisfyingVersion; let versionStringToObject; let minInstalledSatisfyingVersion; beforeEach(function() { sinon.stub(engine, 'properNodeVersions'); @@ -516,8 +516,8 @@ describe('Engine Module', function() { versionStringToObject = sinon.stub(engine, 'versionStringToObject'); satisfyingVersions = sinon.stub(engine, 'satisfyingVersions'); }); - it('should throw when specified version is not compatible with target repository', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^8.11.1 || ^10.13.0 || ^12.13.0'); + it('should throw when specified version is not compatible with target repository', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); const satVersions = []; satisfyingVersions.callsFake(() => satVersions); @@ -525,10 +525,18 @@ describe('Engine Module', function() { const expectedMessage = 'requires NodeJS version(s) \'^8.11.1 || ^10.13.0 || ^12.13.0\' but got \'14.1.1\''; const path = ''; const version = '14.1.1'; - expect(() => engine.versionToUseValidator({ path, version })).to.Throw(RangeError, expectedMessage); + + try { + await engine.versionToUseValidator({ path, version }); + expect.fail('expected to throw'); + } + catch (error) { + expect(error).to.be.instanceOf(RangeError); + expect(error.message).to.include(expectedMessage); + } }); - it('should be v12.13.1', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^8.11.1 || ^10.13.0 || ^12.13.0'); + it('should be v12.13.1', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); const satVersions = [ { version: 'v12.13.1' }, { version: 'v12.19.1' }, { version: 'v8.11.1' } ]; satisfyingVersions.callsFake(() => satVersions); @@ -537,15 +545,34 @@ describe('Engine Module', function() { const path = ''; const version = '12.13.1'; - const result = engine.versionToUseValidator({ path, version }); + const result = await engine.versionToUseValidator({ path, version }); expect(result.version).to.equal(expectedVersion); }); - it('should pick 16.13.0 -- issue 116', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^12.13.0 || ^14.15.0 || ^16.13.0'); - const satVersions = [ { version: 'v17.0.1' }, { version: 'v16.13.0' }, { version: 'v16.12.0' }, { version: 'v14.18.1' } ]; + it('should pick 16.13.0 -- issue 116', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^12.13.0 || ^14.15.0 || ^16.13.0'); + const satVersions = [ { version: 'v16.13.0' }, { version: 'v16.12.0' }, { version: 'v14.18.1' } ]; satisfyingVersions.callsFake(() => satVersions); - versionStringToObject.callsFake(() => ({ version: 'v12.13.1' })); + versionStringToObject.callsFake(() => ({ version: 'v16.13.0' })); + const path = ''; + const version = '16.13.0'; + + const result = await engine.versionToUseValidator({ path, version }); + + expect(result.version).to.equal('v16.13.0'); + }); + it('should pick oldest when oldest is true', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^12.13.0 || ^14.15.0 || ^16.13.0'); + const satVersions = [ { version: 'v16.13.0' }, { version: 'v16.12.0' }, { version: 'v14.18.1' } ]; + satisfyingVersions.callsFake(() => satVersions); + versionStringToObject.callsFake(() => ({ version: 'v14.18.1' })); + const path = ''; + const version = '14.18.1'; + const oldest = true; + + const result = await engine.versionToUseValidator({ path, version, oldest }); + + expect(result.version).to.equal('v14.18.1'); }); }); describe('default', function() { @@ -553,33 +580,67 @@ describe('Engine Module', function() { maxInstalledSatisfyingVersion = sinon.stub(engine, 'maxInstalledSatisfyingVersion'); minInstalledSatisfyingVersion = sinon.stub(engine, 'minInstalledSatisfyingVersion'); }); - it('should throw when version not specified and not installed versions satisfy', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^8.11.1 || ^10.13.0 || ^12.13.0'); + it('should throw when version not specified and no installed versions satisfy', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); maxInstalledSatisfyingVersion.callsFake(() => undefined); const expectedMessage = 'repo requires NodeJS version(s) \'^8.11.1 || ^10.13.0 || ^12.13.0\' but no satisfying versions installed!'; const path = 'projects/repo'; - expect(() => engine.versionToUseValidator({ path })).to.Throw(RangeError, expectedMessage); + try { + await engine.versionToUseValidator({ path }); + expect.fail('expected to throw'); + } + catch (error) { + expect(error).to.be.instanceOf(RangeError); + expect(error.message).to.equal(expectedMessage); + } }); - it('should be 12.11.1 when oldest not specified', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^8.11.1 || ^10.13.0 || ^12.13.0'); + it('should be 12.11.1 when oldest not specified', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); maxInstalledSatisfyingVersion.callsFake(() => ({ version: 'v12.11.1' })); const path = 'projects/repo'; - const result = engine.versionToUseValidator({ path }); + const result = await engine.versionToUseValidator({ path }); expect(result.version).to.equal('v12.11.1'); }); - it('should be 12.11.1 when oldest is true', function() { - sinon.stub(engine, 'repositoryEngines').callsFake(() => '^8.11.1 || ^10.13.0 || ^12.13.0'); + it('should be 12.11.1 when oldest is true', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); minInstalledSatisfyingVersion.callsFake(() => ({ version: 'v12.11.1' })); const path = 'projects/repo'; const oldest = true; - const result = engine.versionToUseValidator({ path, oldest }); + const result = await engine.versionToUseValidator({ path, oldest }); expect(result.version).to.equal('v12.11.1'); }); + it('should use max when oldest is false and min is not needed', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); + maxInstalledSatisfyingVersion.callsFake(() => ({ version: 'v12.19.1' })); + const path = 'projects/repo'; + const oldest = false; + + const result = await engine.versionToUseValidator({ path, oldest }); + + expect(result.version).to.equal('v12.19.1'); + expect(minInstalledSatisfyingVersion).not.called; + }); + it('should throw when oldest is true and no satisfying versions installed', async function() { + sinon.stub(engine, 'repositoryEngines').resolves('^8.11.1 || ^10.13.0 || ^12.13.0'); + minInstalledSatisfyingVersion.callsFake(() => undefined); + maxInstalledSatisfyingVersion.callsFake(() => undefined); + const path = 'projects/repo'; + const oldest = true; + + try { + await engine.versionToUseValidator({ path, oldest }); + expect.fail('expected to throw'); + } + catch (error) { + expect(error).to.be.instanceOf(RangeError); + expect(error.message).to.include('but no satisfying versions installed!'); + } + }); }); }); describe('versionStringToObject()', function() { diff --git a/test/git-seq.spec.js b/test/git-seq.spec.js deleted file mode 100644 index 37557b1..0000000 --- a/test/git-seq.spec.js +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable no-magic-numbers */ - -const sinon = require('sinon'); -const chai = require('chai'); -chai.use(require('sinon-chai')); -const { expect } = chai; - -const gtools = require('../src/common/git-tools'); -const { initBase, destroy } = require('../src/common/temp'); -const { - commitsDiff - , commitDiffCounts - , headLog -} = require('../src/common/git'); - -const logger = {}; - -describe.skip('Git module - sequential tests', function() { - before(initBase); - after(destroy); - describe('run', function() { - this.timeout(14000); - // must run in sequence due to shared repositories - let origin; let local1; let local2; - before(async function() { - try { - origin = await gtools.createBareRepo(); - local1 = await gtools.createRepo('afile'); - await gtools.addRemote(local1, origin); - await gtools.push(local1); - local2 = await gtools.duplicateRepo(local1); - await gtools.addCommit(local2); - await gtools.addCommitWithMessage(local2, 'hello, world'); - await gtools.push(local2); - await gtools.fetchRemotes(local1); - } - catch (err) { - console.error(err); - expect.fail(); - } - }); - describe('commitsDiff()', function() { - it('should be zero ahead', async function() { - const ahead = await commitsDiff(local1); - - expect(ahead).equals(0); - }); - it('should be 2 in total', async function() { - const total = await commitsDiff(local1, 'master', true); - - expect(total).equals(2); - }); - }); - describe('commitDiffCounts()', function() { - it('should not have error', async function() { - const { ahead, behind, error } = await commitDiffCounts(local1); - - expect(ahead).equals(0); - expect(behind).equals(2); - expect(error).to.be.undefined; - }); - it('should have error', async function() { - const { error } = await commitDiffCounts(''); - - expect(error).not.to.be.undefined; - }); - }); - describe('headLog()', function() { - it('should be \'hello, world\'', async function() { - const hello = 'hello, world\n\n'; - logger.error = sinon.spy(); - const result = await headLog(local2, logger); - - expect(result).equals(hello.trim()); - }); - }); - }); -}); \ No newline at end of file diff --git a/test/git.spec.js b/test/git.spec.js deleted file mode 100644 index 1fefc18..0000000 --- a/test/git.spec.js +++ /dev/null @@ -1,127 +0,0 @@ - -// git library module -// -- tests are slow due to an inability to mock git repositories - -/* eslint no-magic-numbers:off */ -/* eslint-env mocha */ - -const chai = require('chai'); -chai.use(require('sinon-chai')); -const { expect } = chai; - -const gtools = require('../src/common/git-tools'); -const { initBase, destroy } = require('../src/common/temp'); -const { - currentBranch - , currentHash - , isDirty - , hasCommits -} = require('../src/common/git'); - -// how might I check if the the git config user.name and user.email are configured? -async function checkGitConfig() { - const git = gtools.git.simpleGit(); - - try { - let userName = await git.raw([ 'config', 'user.name' ]); - let userEmail = await git.raw([ 'config', 'user.email' ]); - - if(!userName) { - userName = 'Jim Dandy'; - await git.raw([ 'config', '--global', 'user.name', userName ]); - } - if(!userEmail) { - userEmail = 'jim@example.com'; - // set global config - await git.raw([ 'config', '--global', 'user.email', userEmail ]); - } - } - catch { - // handle error - console.error('Failed to check git config'); - } -} - - -describe('git module - non-sequential', function() { - - this.timeout(12000); - before(async() => { - initBase(); - await checkGitConfig(); - }); - after(destroy); - describe('currentBranch()', function() { - it('should be test branch', async function() { - const repo = await gtools.createRepo('afile'); - await gtools.addCommit(repo, null, 'test'); - - const result = await currentBranch(repo); - - expect(result).to.equal('test'); - }); - it('should be the default branch', async function() { - const repo = await gtools.createRepo('afile'); - await gtools.addCommit(repo); - - const result = await currentBranch(repo); - - // determine default branch - - const br = await gtools.git.simpleGit(repo).revparse([ '--abbrev-ref', 'HEAD' ]); - expect(result).to.equal(br); - }); - it('should be HEAD if no commit', async function() { - const repo = await gtools.createBareRepo(); - - const result = await currentBranch(repo); - - expect(result).to.equal('HEAD'); - }); - }); - describe('currentHash()', function() { - it('should be valid head commit hash', async function() { - const repo = await gtools.createRepo('afile'); - await gtools.addCommit(repo); - - const result = await currentHash(repo); - - expect(result).not.to.be.empty; - expect(result).to.match(/^[a-f0-9]{40}$/g); - }); - }); - describe('isDirty', function() { - it('should be true', async function() { - const repo = await gtools.createRepo('afile'); - await gtools.addFileToRepo(repo, 'bfile'); - - const result = await isDirty(repo); - - expect(result).to.be.true; - }); - it('should be false', async function() { - const repo = await gtools.createRepo('afile'); - await gtools.addFileToRepo(repo, 'bfile', { stage: true, commit: true }); - - const result = await isDirty(repo); - - expect(result).to.be.false; - }); - }); - describe('hasCommits()', function() { - it('should not have error property when commits present', async function() { - const repo = await gtools.createRepo('afile'); - - const { error } = await hasCommits(repo); - - expect(error).to.be.undefined; - }); - it('should have error property when not commits present', async function() { - const repo = await gtools.createBareRepo(); - - const { error } = await hasCommits(repo); - - expect(error).to.equal(1); - }); - }); -}); diff --git a/test/temp.spec.js b/test/temp.spec.js index e8d9e91..55f3b86 100644 --- a/test/temp.spec.js +++ b/test/temp.spec.js @@ -63,15 +63,8 @@ describe('Temp Folder utility', () => { }); describe('destroy()', () => { it('should remove system base', () => { - mockFS({ 'folly': {} }); - TF.baseFolder = './folly'; - let subs = fs.readdirSync('.'); - expect(subs).to.contain('folly'); - - TF.destroy(); - subs = fs.readdirSync('.'); - - expect(subs).not.to.contain('folly'); + TF.initBase(); // creates a real temp dir + TF.destroy(); // removes it expect(TF.baseFolder).to.be.undefined; }); }); diff --git a/yarn.lock b/yarn.lock index a4eb19d..456c05f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -420,6 +420,22 @@ __metadata: languageName: node linkType: hard +"@simple-git/args-pathspec@npm:^1.0.3": + version: 1.0.3 + resolution: "@simple-git/args-pathspec@npm:1.0.3" + checksum: 10/74bcc00123cd0da800fc5a6417f83f2f4fce66eb38749efc2ca3b6e4c431c6edaabe80c5834714c155f6ba07582069b182420eda27a601353fb5a3b48917d7e0 + languageName: node + linkType: hard + +"@simple-git/argv-parser@npm:^1.1.0": + version: 1.1.1 + resolution: "@simple-git/argv-parser@npm:1.1.1" + dependencies: + "@simple-git/args-pathspec": "npm:^1.0.3" + checksum: 10/89f49ee1e9a936318d443a21b04fa8adcd7a3fa4d2d9ebc947d6d4f347d65db5fbcfec37f3d75711f2724d7886506f3003144735e244e1eea6badef58696d99f + languageName: node + linkType: hard + "@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" @@ -2443,10 +2459,10 @@ __metadata: randomatic: "npm:^3.1.1" semver: "npm:^7.7.3" shelljs: "npm:^0.10.0" - simple-git: "npm:^3.32.3" + simple-git: "npm:^3.36.0" sinon: "npm:^19.0.4" sinon-chai: "npm:^3.7.0" - uuid: "npm:11.1.0" + uuid: "npm:11.1.1" yargs: "npm:^17.7.2" languageName: unknown linkType: soft @@ -3062,14 +3078,16 @@ __metadata: languageName: node linkType: hard -"simple-git@npm:^3.32.3": - version: 3.33.0 - resolution: "simple-git@npm:3.33.0" +"simple-git@npm:^3.36.0": + version: 3.36.0 + resolution: "simple-git@npm:3.36.0" dependencies: "@kwsites/file-exists": "npm:^1.1.1" "@kwsites/promise-deferred": "npm:^1.1.1" + "@simple-git/args-pathspec": "npm:^1.0.3" + "@simple-git/argv-parser": "npm:^1.1.0" debug: "npm:^4.4.0" - checksum: 10/5175133df68053f52db3c160e060939ec6584409378e89cc5e57f60a00739a507a99a7dbcec0401925fdfdca9c9c60ec6ef84d9669e815d0806f4c18bf7aa820 + checksum: 10/79a745464798bce17fc5c286ff4a44ba07d368a745d03d31f3c3cef51bd63b98dc29e72f55dda087b64e5db90e5e9f91a7429543ddf08d105d9a069c525e6818 languageName: node linkType: hard @@ -3399,12 +3417,12 @@ __metadata: languageName: node linkType: hard -"uuid@npm:11.1.0": - version: 11.1.0 - resolution: "uuid@npm:11.1.0" +"uuid@npm:11.1.1": + version: 11.1.1 + resolution: "uuid@npm:11.1.1" bin: uuid: dist/esm/bin/uuid - checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + checksum: 10/16411d3dc12a08d6691616c09a75e66a7f900ba1beef6628a76fe0602f82fae2ee537b564d0b7bc95c24f58d059ca9b58c75a1e806118efb50e17822ff00ddd2 languageName: node linkType: hard From 428ad7b7dea26e7d5c3f127818d70de730a6cfad Mon Sep 17 00:00:00 2001 From: Todd Hogarth Date: Fri, 29 May 2026 09:40:57 -0400 Subject: [PATCH 4/4] Throttle repo processing and fix error reporting Throttle repo processing by batching repos into concurrent chunks of 8 to avoid spawning too many parallel tasks and to reduce resource contention. Strengthen error reporting by narrowing the errors param to an array of {repo, error} and by recording the full repo path rather than the basename when pushing errors. Update tests to import node:fs and node:path and fix a typo in a test description; minor cleanup of temp init/destroy calls. Changed files: - src/grefplus/index.js - test/temp.spec.js --- src/grefplus/index.js | 14 ++++++++++---- test/temp.spec.js | 10 +++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/grefplus/index.js b/src/grefplus/index.js index 2576f7f..869ccb0 100644 --- a/src/grefplus/index.js +++ b/src/grefplus/index.js @@ -49,7 +49,7 @@ const filterPeriod = (item) => { /** * Obtains the git reflogs result * @param {string} repo - full path to a repository - * @param {Array} errors - place to store skippable errors + * @param {{repo:string, error:string}[]} errors - place to store skippable errors * @return {Promise<{date: DateTime, body: string, repo: string}[]>} objects containing date, body, and the repository base name */ async function processRepo(repo, errors) { @@ -76,7 +76,7 @@ async function processRepo(repo, errors) { return lines; } catch (err) { - errors.push({ repo: basename(repo), error: err ? err.message : 'Unknown error' }); + errors.push({ repo, error: err ? err.message : 'Unknown error' }); // continue to next repo but be sure to return empty array return []; } @@ -117,10 +117,16 @@ async function main() { const paths = await allRepoPaths(root, options.folderNames); repos.push(...paths); } - const promises = repos.map(repo => processRepo(repo, errors)); try { - const result = await Promise.all(promises); + const result = []; + const concurrency = 8; + for(let i = 0; i < repos.length; i += concurrency) { + const batch = repos + .slice(i, i + concurrency) + .map(repo => processRepo(repo, errors)); + result.push(...await Promise.all(batch)); + } const sorted = result .flat() .sort((a, b) => a.date.valueOf() - b.date.valueOf()); diff --git a/test/temp.spec.js b/test/temp.spec.js index 55f3b86..f6bdb20 100644 --- a/test/temp.spec.js +++ b/test/temp.spec.js @@ -2,8 +2,8 @@ const chai = require('chai'); const { expect } = chai; const mockFS = require('mock-fs'); -const { dirname } = require('path'); -const fs = require('fs'); +const { dirname } = require('node:path'); +const fs = require('node:fs'); const options = {}; const TF = require('../src/common/temp'); @@ -37,7 +37,7 @@ describe('Temp Folder utility', () => { expect(TF.baseFolder).not.to.be.undefined; }); - it('should not set base more than onece', () => { + it('should not set base more than once', () => { expect(TF.baseFolder).to.be.undefined; const first = TF.initBase(); @@ -63,8 +63,8 @@ describe('Temp Folder utility', () => { }); describe('destroy()', () => { it('should remove system base', () => { - TF.initBase(); // creates a real temp dir - TF.destroy(); // removes it + TF.initBase(); + TF.destroy(); expect(TF.baseFolder).to.be.undefined; }); });