From 9e309fa21259424b51b72210292951779cb6fcf6 Mon Sep 17 00:00:00 2001 From: Philippe Collin Date: Fri, 9 Oct 2026 12:21:57 -0400 Subject: [PATCH 1/2] Don't run Git in the target directory during `theme pull` `shopify theme pull` checked for uncommitted changes by running `git status` in the target directory. Git reads its settings from that directory, which isn't always trusted, so remove the check and avoid invoking Git on the pull target entirely. Co-Authored-By: Claude Opus 4.8 --- .changeset/theme-pull-skip-git-check.md | 5 +++++ packages/theme/src/cli/services/pull.test.ts | 23 -------------------- packages/theme/src/cli/services/pull.ts | 19 ---------------- 3 files changed, 5 insertions(+), 42 deletions(-) create mode 100644 .changeset/theme-pull-skip-git-check.md diff --git a/.changeset/theme-pull-skip-git-check.md b/.changeset/theme-pull-skip-git-check.md new file mode 100644 index 00000000000..9604a0295fb --- /dev/null +++ b/.changeset/theme-pull-skip-git-check.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +`shopify theme pull` no longer runs Git in the target directory to detect uncommitted changes, so it never reads settings from a repository that happens to live in that directory diff --git a/packages/theme/src/cli/services/pull.test.ts b/packages/theme/src/cli/services/pull.test.ts index 3656d80bee2..99d33fed5f3 100644 --- a/packages/theme/src/cli/services/pull.test.ts +++ b/packages/theme/src/cli/services/pull.test.ts @@ -12,7 +12,6 @@ import {mkTmpDir, rmdir} from '@shopify/cli-kit/node/fs' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {fetchChecksums} from '@shopify/cli-kit/node/themes/api' -import {insideGitDirectory, isClean} from '@shopify/cli-kit/node/git' import {test, describe, expect, vi, beforeEach} from 'vitest' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {renderSuccess} from '@shopify/cli-kit/node/ui' @@ -28,7 +27,6 @@ vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/themes/api') vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/git') const adminSession = {token: '', storeFqdn: ''} const path = '/my-theme' @@ -99,25 +97,6 @@ describe('pull', () => { ) }) - test('should ask for confirmation if the current directory is a Git directory and is not clean', async () => { - // Given - const theme = buildTheme({id: 1, name: 'Theme', role: 'development'})! - vi.mocked(insideGitDirectory).mockResolvedValue(true) - vi.mocked(isClean).mockResolvedValue(false) - vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(false) - - // When - await pull({...defaultFlags, theme: theme.id.toString()}) - - // Then - expect(vi.mocked(ensureDirectoryConfirmed)).toHaveBeenCalledWith( - false, - 'The current Git directory has uncommitted changes.', - undefined, - undefined, - ) - }) - test('should not ask for confirmation if --force flag is provided', async () => { // Given const theme = buildTheme({id: 1, name: 'Theme', role: 'development'})! @@ -130,8 +109,6 @@ describe('pull', () => { // Then expect(vi.mocked(findOrSelectTheme)).toHaveBeenCalledOnce() expect(vi.mocked(hasRequiredThemeDirectories)).not.toHaveBeenCalled() - expect(vi.mocked(insideGitDirectory)).not.toHaveBeenCalled() - expect(vi.mocked(isClean)).not.toHaveBeenCalled() expect(vi.mocked(ensureDirectoryConfirmed)).not.toHaveBeenCalled() }) diff --git a/packages/theme/src/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index de4835e194d..88b131d0e0b 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -12,7 +12,6 @@ import {fetchChecksums} from '@shopify/cli-kit/node/themes/api' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {glob} from '@shopify/cli-kit/node/fs' import {cwd} from '@shopify/cli-kit/node/path' -import {insideGitDirectory, isClean} from '@shopify/cli-kit/node/git' import {recordTiming} from '@shopify/cli-kit/node/analytics' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {Writable} from 'stream' @@ -233,23 +232,5 @@ async function validateDirectory(path: string, force: boolean, environment?: str return false } - /** - * If users are not forcing the 'pull' command, and the current directory is a - * Git directory and it is not clean, we ask for confirmation before proceeding. - */ - const dirtyDirectory = (await insideGitDirectory(path)) && !(await isClean(path)) - - if ( - dirtyDirectory && - !(await ensureDirectoryConfirmed( - force, - 'The current Git directory has uncommitted changes.', - environment, - multiEnvironment, - )) - ) { - return false - } - return true } From 6fccdf64565e7164a370a3255eec0554b878a0c6 Mon Sep 17 00:00:00 2001 From: Philippe Collin Date: Fri, 9 Oct 2026 19:13:07 -0400 Subject: [PATCH 2/2] Confirm before `theme pull` overwrites or deletes local files List the local files a pull would overwrite or delete and ask for confirmation before changing them. `--force` skips the prompt, and when prompting isn't possible the files are listed as a warning and the pull continues, matching the existing directory confirmation. Co-Authored-By: Claude Opus 5.5 --- .changeset/theme-pull-skip-git-check.md | 4 +- packages/cli/oclif.manifest.json | 2 +- packages/theme/src/cli/commands/theme/pull.ts | 3 +- packages/theme/src/cli/services/pull.test.ts | 35 ++++++- packages/theme/src/cli/services/pull.ts | 11 ++- .../cli/utilities/theme-downloader.test.ts | 49 +++++++++- .../src/cli/utilities/theme-downloader.ts | 53 +++++++---- .../theme/src/cli/utilities/theme-ui.test.ts | 91 ++++++++++++++++++- packages/theme/src/cli/utilities/theme-ui.ts | 43 +++++++++ 9 files changed, 262 insertions(+), 29 deletions(-) diff --git a/.changeset/theme-pull-skip-git-check.md b/.changeset/theme-pull-skip-git-check.md index 9604a0295fb..1b41002e83a 100644 --- a/.changeset/theme-pull-skip-git-check.md +++ b/.changeset/theme-pull-skip-git-check.md @@ -1,5 +1,5 @@ --- -'@shopify/theme': patch +'@shopify/theme': minor --- -`shopify theme pull` no longer runs Git in the target directory to detect uncommitted changes, so it never reads settings from a repository that happens to live in that directory +`shopify theme pull` now lists the local files it would overwrite or delete and asks for confirmation before changing them, replacing the check for uncommitted Git changes. Use `--force` to skip the confirmation. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7eec420b307..edce39623ab 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12288,7 +12288,7 @@ "force": { "allowNo": false, "char": "f", - "description": "Proceed without confirmation, if current directory does not seem to be theme directory.", + "description": "Proceed without confirmation, if current directory does not seem to be theme directory or local files would be overwritten.", "env": "SHOPIFY_FLAG_FORCE", "hidden": true, "name": "force", diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 605eaaa5dd5..7ea9107ecd7 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -50,7 +50,8 @@ If no theme is specified, then you're prompted to select the theme to pull from force: Flags.boolean({ hidden: true, char: 'f', - description: 'Proceed without confirmation, if current directory does not seem to be theme directory.', + description: + 'Proceed without confirmation, if current directory does not seem to be theme directory or local files would be overwritten.', env: 'SHOPIFY_FLAG_FORCE', }), } diff --git a/packages/theme/src/cli/services/pull.test.ts b/packages/theme/src/cli/services/pull.test.ts index 99d33fed5f3..d9999d508b1 100644 --- a/packages/theme/src/cli/services/pull.test.ts +++ b/packages/theme/src/cli/services/pull.test.ts @@ -5,8 +5,8 @@ import {ensureThemeStore} from '../utilities/theme-store.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {fakeThemeFileSystem} from '../utilities/theme-fs/theme-fs-mock-factory.js' -import {downloadTheme} from '../utilities/theme-downloader.js' -import {themeComponent, ensureDirectoryConfirmed} from '../utilities/theme-ui.js' +import {downloadTheme, localFileChanges} from '../utilities/theme-downloader.js' +import {themeComponent, ensureDirectoryConfirmed, ensureLocalFileChangesConfirmed} from '../utilities/theme-ui.js' import {mkTmpDir, rmdir} from '@shopify/cli-kit/node/fs' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' @@ -55,6 +55,7 @@ describe('pull', () => { vi.mocked(mountThemeFileSystem).mockReturnValue(localThemeFileSystem) vi.mocked(fetchChecksums).mockResolvedValue([]) vi.mocked(themeComponent).mockReturnValue([]) + vi.mocked(ensureLocalFileChangesConfirmed).mockResolvedValue(true) findDevelopmentThemeSpy.mockClear() fetchDevelopmentThemeSpy.mockClear() }) @@ -112,6 +113,36 @@ describe('pull', () => { expect(vi.mocked(ensureDirectoryConfirmed)).not.toHaveBeenCalled() }) + test('should ask for confirmation before changing local files', async () => { + // Given + const theme = buildTheme({id: 1, name: 'Theme', role: 'development'})! + const changes = {overwritten: ['templates/index.json'], deleted: ['assets/old.css']} + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(localFileChanges).mockReturnValue(changes) + + // When + await pull({...defaultFlags, theme: theme.id.toString()}) + + // Then + expect(localFileChanges).toHaveBeenCalledWith([], localThemeFileSystem, expect.objectContaining({nodelete: false})) + expect(ensureLocalFileChangesConfirmed).toHaveBeenCalledWith(changes, false, undefined, undefined) + expect(downloadTheme).toHaveBeenCalled() + }) + + test('should not change local files when the confirmation is declined', async () => { + // Given + const theme = buildTheme({id: 1, name: 'Theme', role: 'development'})! + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(ensureLocalFileChangesConfirmed).mockResolvedValue(false) + + // When + await pull({...defaultFlags, theme: theme.id.toString()}) + + // Then + expect(downloadTheme).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + }) + test('should render success message with theme preview and editor links', async () => { // Given const theme = buildTheme({id: 1, name: 'Theme', role: 'development'})! diff --git a/packages/theme/src/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index 88b131d0e0b..a4e7826ea0a 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -1,6 +1,6 @@ -import {downloadTheme} from '../utilities/theme-downloader.js' +import {downloadTheme, localFileChanges} from '../utilities/theme-downloader.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' -import {ensureDirectoryConfirmed, themeComponent} from '../utilities/theme-ui.js' +import {ensureDirectoryConfirmed, ensureLocalFileChangesConfirmed, themeComponent} from '../utilities/theme-ui.js' import {rejectGeneratedStaticAssets} from '../utilities/asset-checksum.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' @@ -73,7 +73,7 @@ export interface PullFlags { ignore?: string[] /** - * Proceed without confirmation, if current directory does not seem to be theme directory. + * Proceed without confirmation, if current directory does not seem to be theme directory or local files would be overwritten. */ force?: boolean @@ -165,6 +165,11 @@ async function executePull( const themeChecksums = rejectGeneratedStaticAssets(remoteChecksums) recordTiming('theme-service:pull:file-system') + const changes = localFileChanges(themeChecksums, themeFileSystem, options) + if (!(await ensureLocalFileChangesConfirmed(changes, options.force, options.environment, options.multiEnvironment))) { + return + } + await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) const header = options.environment ? `Environment: ${options.environment}` : '' diff --git a/packages/theme/src/cli/utilities/theme-downloader.test.ts b/packages/theme/src/cli/utilities/theme-downloader.test.ts index 6bb6fe27657..18e8002dd19 100644 --- a/packages/theme/src/cli/utilities/theme-downloader.test.ts +++ b/packages/theme/src/cli/utilities/theme-downloader.test.ts @@ -1,5 +1,5 @@ import {fakeThemeFileSystem} from './theme-fs/theme-fs-mock-factory.js' -import {downloadTheme} from './theme-downloader.js' +import {downloadTheme, localFileChanges} from './theme-downloader.js' import {fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' import {Checksum, ThemeAsset} from '@shopify/cli-kit/node/themes/types' import {test, describe, expect, vi} from 'vitest' @@ -108,4 +108,51 @@ describe('theme-downloader', () => { expect(spy).toHaveBeenCalledWith(fileToDownload) }) }) + + describe('localFileChanges', () => { + const root = 'tmp' + const files = new Map([ + ['assets/unchanged.css', {key: 'assets/unchanged.css', checksum: '1', value: 'content'}], + ['assets/changed.css', {key: 'assets/changed.css', checksum: '2', value: 'content'}], + ['assets/local-only.css', {key: 'assets/local-only.css', checksum: '3', value: 'content'}], + ]) + const remote = [ + {key: 'assets/unchanged.css', checksum: '1'}, + {key: 'assets/changed.css', checksum: 'changed'}, + {key: 'assets/remote-only.css', checksum: '4'}, + ] + + test('returns the local files that would be overwritten or deleted', () => { + // Given + const fileSystem = fakeThemeFileSystem(root, files) + + // When + const changes = localFileChanges(remote, fileSystem, {nodelete: false}) + + // Then + expect(changes).toEqual({overwritten: ['assets/changed.css'], deleted: ['assets/local-only.css']}) + }) + + test('does not return deleted files when nodelete is set', () => { + // Given + const fileSystem = fakeThemeFileSystem(root, files) + + // When + const changes = localFileChanges(remote, fileSystem, {nodelete: true}) + + // Then + expect(changes).toEqual({overwritten: ['assets/changed.css'], deleted: []}) + }) + + test('does not return files excluded by filters', () => { + // Given + const fileSystem = fakeThemeFileSystem(root, files, {filters: {ignore: ['assets/changed.css']}}) + + // When + const changes = localFileChanges(remote, fileSystem, {nodelete: false}) + + // Then + expect(changes).toEqual({overwritten: [], deleted: ['assets/local-only.css']}) + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-downloader.ts b/packages/theme/src/cli/utilities/theme-downloader.ts index a32dd8effe0..206bb653474 100644 --- a/packages/theme/src/cli/utilities/theme-downloader.ts +++ b/packages/theme/src/cli/utilities/theme-downloader.ts @@ -33,15 +33,46 @@ export async function downloadTheme( } } -function buildDeleteTasks(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem, options: DownloadOptions) { +export interface LocalFileChanges { + overwritten: string[] + deleted: string[] +} + +/** + * Returns the existing local files that downloading the remote theme would overwrite or delete. + */ +export function localFileChanges( + remoteChecksums: Checksum[], + themeFileSystem: ThemeFileSystem, + options: DownloadOptions, +): LocalFileChanges { + return { + overwritten: filesToDownload(remoteChecksums, themeFileSystem).filter((key) => themeFileSystem.files.has(key)), + deleted: filesToDelete(remoteChecksums, themeFileSystem, options), + } +} + +function filesToDelete(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem, options: DownloadOptions) { if (options.nodelete) return [] const remoteKeys = new Set(remoteChecksums.map((checksum) => checksum.key)) const localKeys = themeFileSystem.applyIgnoreFilters([...themeFileSystem.files.values()]).map(({key}) => key) - const localFilesToBeDeleted = localKeys.filter((key) => !remoteKeys.has(key)) + return localKeys.filter((key) => !remoteKeys.has(key)) +} + +function filesToDownload(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem) { + return ( + themeFileSystem + .applyIgnoreFilters(remoteChecksums) + // Filter out files we already have + .filter((checksum) => themeFileSystem.files.get(checksum.key)?.checksum !== checksum.checksum) + .map((checksum) => checksum.key) + ) +} - return localFilesToBeDeleted.map((key) => { +function buildDeleteTasks(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem, options: DownloadOptions) { + return filesToDelete(remoteChecksums, themeFileSystem, options).map((key) => { return { title: `Cleaning your local directory (removing ${key})`, task: async () => themeFileSystem.delete(key), @@ -55,21 +86,7 @@ function buildDownloadTasks( themeFileSystem: ThemeFileSystem, session: AdminSession, ): Task[] { - let checksums = themeFileSystem.applyIgnoreFilters(remoteChecksums) - - // Filter out files we already have - checksums = checksums.filter((checksum) => { - const remoteChecksumValue = checksum.checksum - const localAsset = themeFileSystem.files.get(checksum.key) - - if (localAsset?.checksum === remoteChecksumValue) { - return false - } else { - return true - } - }) - - const filenames = checksums.map((checksum) => checksum.key) + const filenames = filesToDownload(remoteChecksums, themeFileSystem) const getProgress = (params: {current: number; total: number}) => params.total === 0 ? `[100%]` : `[${Math.round((params.current / params.total) * 100)}%]` diff --git a/packages/theme/src/cli/utilities/theme-ui.test.ts b/packages/theme/src/cli/utilities/theme-ui.test.ts index 771d06e5345..2bfbce9e5c8 100644 --- a/packages/theme/src/cli/utilities/theme-ui.test.ts +++ b/packages/theme/src/cli/utilities/theme-ui.test.ts @@ -1,4 +1,10 @@ -import {themeComponent, themesComponent, ensureDirectoryConfirmed, ensureLiveThemeConfirmed} from './theme-ui.js' +import { + themeComponent, + themesComponent, + ensureDirectoryConfirmed, + ensureLiveThemeConfirmed, + ensureLocalFileChangesConfirmed, +} from './theme-ui.js' import {Theme} from '@shopify/cli-kit/node/themes/types' import {renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' import {test, describe, expect, vi, afterEach, beforeEach} from 'vitest' @@ -96,6 +102,89 @@ describe('ensureDirectoryConfirmed', () => { }) }) +describe('ensureLocalFileChangesConfirmed', () => { + const changes = {overwritten: ['templates/index.json'], deleted: ['assets/old.css', 'assets/older.css']} + const warning = { + headline: '', + body: [ + 'Pulling this theme will overwrite 1 local file and delete 2 local files:', + {list: {items: ['templates/index.json', 'assets/old.css', 'assets/older.css']}}, + ], + } + + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + stdin: {...process.stdin, isTTY: true}, + stderr: {...process.stderr, isTTY: true}, + }) + }) + + test('prompts for confirmation when local files would be overwritten or deleted', async () => { + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + + const confirmed = await ensureLocalFileChangesConfirmed(changes, false) + + expect(renderWarning).toHaveBeenCalledWith(warning) + expect(renderConfirmationPrompt).toHaveBeenCalledWith({ + message: 'Do you want to proceed?', + confirmationMessage: 'Yes, update my local files', + cancellationMessage: 'No, cancel', + }) + expect(confirmed).toBe(false) + }) + + test('does not prompt when no local files would change', async () => { + const confirmed = await ensureLocalFileChangesConfirmed({overwritten: [], deleted: []}, false) + + expect(renderWarning).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(confirmed).toBe(true) + }) + + test('does not prompt when force flag is true', async () => { + const confirmed = await ensureLocalFileChangesConfirmed(changes, true) + + expect(renderWarning).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(confirmed).toBe(true) + }) + + test('lists a limited number of files', async () => { + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + const overwritten = Array.from({length: 12}, (_, index) => `snippets/file-${index}.liquid`) + + await ensureLocalFileChangesConfirmed({overwritten, deleted: []}, false) + + expect(renderWarning).toHaveBeenCalledWith({ + headline: '', + body: [ + 'Pulling this theme will overwrite 12 local files:', + {list: {items: overwritten.slice(0, 10)}}, + 'and 2 more.', + ], + }) + }) + + test('warns and proceeds when prompting is unavailable', async () => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true') + + const confirmed = await ensureLocalFileChangesConfirmed(changes, false) + + expect(renderWarning).toHaveBeenCalledWith(warning) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(confirmed).toBe(true) + }) + + test('warns and proceeds during a multi environment command run', async () => { + const confirmed = await ensureLocalFileChangesConfirmed(changes, false, 'Production', true) + + expect(renderWarning).toHaveBeenCalledWith({...warning, headline: 'Environment: Production'}) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(confirmed).toBe(true) + }) +}) + describe('ensureLiveThemeConfirmed', () => { const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! const liveTheme = buildTheme({id: 123, name: 'My Theme', role: LIVE_THEME_ROLE})! diff --git a/packages/theme/src/cli/utilities/theme-ui.ts b/packages/theme/src/cli/utilities/theme-ui.ts index 7362eb36e0a..4148c9216dd 100644 --- a/packages/theme/src/cli/utilities/theme-ui.ts +++ b/packages/theme/src/cli/utilities/theme-ui.ts @@ -1,3 +1,4 @@ +import {LocalFileChanges} from './theme-downloader.js' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {Theme} from '@shopify/cli-kit/node/themes/types' import {LIVE_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' @@ -87,6 +88,48 @@ export async function ensureLiveThemeConfirmed(theme: Theme, action: string, all return confirm } +const MAX_LISTED_LOCAL_FILES = 10 + +/** + * Asks for confirmation before local files are overwritten or deleted. When + * prompting isn't possible, it warns about the affected files and proceeds. + */ +export async function ensureLocalFileChangesConfirmed( + changes: LocalFileChanges, + force: boolean, + environment?: string, + multiEnvironment?: boolean, +) { + const files = [...changes.overwritten, ...changes.deleted] + if (force || files.length === 0) return true + + const fileCount = (count: number) => (count === 1 ? '1 local file' : `${count} local files`) + const actions = [] + if (changes.overwritten.length > 0) actions.push(`overwrite ${fileCount(changes.overwritten.length)}`) + if (changes.deleted.length > 0) actions.push(`delete ${fileCount(changes.deleted.length)}`) + + const unlistedCount = files.length - MAX_LISTED_LOCAL_FILES + const body = [ + `Pulling this theme will ${actions.join(' and ')}:`, + {list: {items: files.slice(0, MAX_LISTED_LOCAL_FILES)}}, + ...(unlistedCount > 0 ? [`and ${unlistedCount} more.`] : []), + ] + + renderWarning({headline: environment ? `Environment: ${environment}` : '', body}) + + if (multiEnvironment || !terminalSupportsPrompting()) return true + + const confirm = await renderConfirmationPrompt({ + message: 'Do you want to proceed?', + confirmationMessage: 'Yes, update my local files', + cancellationMessage: 'No, cancel', + }) + + recordEvent(`theme-service:confirm-local-file-changes:${confirm}`) + + return confirm +} + // This prevents the progress bar from polluting stdout (important for pipe operations) export async function renderTasksToStdErr(tasks: Task[], stderr?: Writable, noProgressBar = false) { if (tasks.length > 0) {