From 603a0eefba3ceb329c62bbfa8a236788303012ad Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:38:05 +0000 Subject: [PATCH] [Performance] Memoize hasGit check Memoize the result of the `hasGit` check to avoid redundant `git --version` shell executions. Concurrent calls now return the same promise, and subsequent calls return the cached result. Added unit tests to verify memoization and cache reset behavior. --- .../src/public/node/context/local.test.ts | 43 +++++++++++++++++++ .../cli-kit/src/public/node/context/local.ts | 33 +++++++++++--- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/cli-kit/src/public/node/context/local.test.ts b/packages/cli-kit/src/public/node/context/local.test.ts index abe71bb52b7..81941085a40 100644 --- a/packages/cli-kit/src/public/node/context/local.test.ts +++ b/packages/cli-kit/src/public/node/context/local.test.ts @@ -5,6 +5,7 @@ import { isShopify, isTerminalInteractive, isUnitTest, + _resetHasGit, analyticsDisabled, cloudEnvironment, macAddress, @@ -123,6 +124,10 @@ describe('isShopify', () => { }) describe('hasGit', () => { + afterEach(() => { + _resetHasGit() + }) + test('returns false if git --version errors', async () => { // Given vi.mocked(exec).mockRejectedValue(new Error('git not found')) @@ -144,6 +149,44 @@ describe('hasGit', () => { // Then expect(got).toBeTruthy() }) + + test('memoizes the result', async () => { + // Given + vi.mocked(exec).mockResolvedValue(undefined) + + // When + await hasGit() + await hasGit() + + // Then + expect(exec).toHaveBeenCalledTimes(1) + }) + + test('returns the same promise instance', async () => { + // Given + vi.mocked(exec).mockResolvedValue(undefined) + + // When + const promise1 = hasGit() + const promise2 = hasGit() + + // Then + expect(promise1).toBe(promise2) + await expect(promise1).resolves.toBe(true) + }) + + test('clears the cache when _resetHasGit is called', async () => { + // Given + vi.mocked(exec).mockResolvedValue(undefined) + + // When + await hasGit() + _resetHasGit() + await hasGit() + + // Then + expect(exec).toHaveBeenCalledTimes(2) + }) }) describe('analitycsDisabled', () => { diff --git a/packages/cli-kit/src/public/node/context/local.ts b/packages/cli-kit/src/public/node/context/local.ts index ad816399db9..5db539b3a5b 100644 --- a/packages/cli-kit/src/public/node/context/local.ts +++ b/packages/cli-kit/src/public/node/context/local.ts @@ -44,6 +44,11 @@ let memoizedIsVerbose: boolean | undefined */ let memoizedIsUnitTest: boolean | undefined +/** + * Memoized value for the hasGit check. + */ +let memoizedHasGit: Promise | undefined + /** * Returns true if the CLI is running in debug mode. * @@ -236,14 +241,28 @@ export function cloudEnvironment(env: NodeJS.ProcessEnv = process.env): { * * @returns A promise that resolves with the value. */ -export async function hasGit(): Promise { - try { - await lazyExec('git', ['--version']) - return true - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - return false +export function hasGit(): Promise { + if (memoizedHasGit !== undefined) { + return memoizedHasGit } + memoizedHasGit = (async () => { + try { + await lazyExec('git', ['--version']) + return true + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return false + } + })() + return memoizedHasGit +} + +/** + * Resets the memoized value for the hasGit check. + * This is only used for testing purposes. + */ +export function _resetHasGit(): void { + memoizedHasGit = undefined } /**