Skip to content

Commit ba52e0c

Browse files
authored
Merge pull request #6533 from Shopify/handle-git-errors-in-cloning-gracefully
Fix: Validate directory before git clone in theme init (#21372)
2 parents 1f97abe + a407016 commit ba52e0c

File tree

3 files changed

+131
-2
lines changed

3 files changed

+131
-2
lines changed

.changeset/clean-dodos-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Fix: Validate directory before `git clone` attempts

packages/cli-kit/src/public/node/git.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import * as git from './git.js'
2-
import {appendFileSync, fileExistsSync, inTemporaryDirectory, readFileSync, writeFileSync} from './fs.js'
2+
import {
3+
appendFileSync,
4+
fileExists,
5+
fileExistsSync,
6+
glob,
7+
inTemporaryDirectory,
8+
isDirectory,
9+
readFileSync,
10+
writeFileSync,
11+
} from './fs.js'
312
import {hasGit} from './context/local.js'
413
import {beforeEach, describe, expect, test, vi} from 'vitest'
514
import simpleGit from 'simple-git'
@@ -38,6 +47,9 @@ vi.mock('./fs.js', async () => {
3847
return {
3948
...fs,
4049
appendFileSync: vi.fn(),
50+
fileExists: vi.fn(),
51+
isDirectory: vi.fn(),
52+
glob: vi.fn(),
4153
}
4254
})
4355

@@ -151,6 +163,83 @@ describe('downloadRepository()', async () => {
151163
expect(mockedClone).toHaveBeenCalledWith('http://repoUrl', destination, options)
152164
expect(mockCheckout).toHaveBeenCalledWith(expectedLatestTag)
153165
})
166+
167+
test('throws when destination exists as a file', async () => {
168+
await expect(async () => {
169+
// Given
170+
const repoUrl = 'http://repoUrl'
171+
const destination = 'destination'
172+
vi.mocked(fileExists).mockResolvedValue(true)
173+
vi.mocked(isDirectory).mockResolvedValue(false)
174+
175+
// When
176+
await git.downloadGitRepository({repoUrl, destination})
177+
178+
// Then
179+
}).rejects.toThrowError(/Can't clone to/)
180+
})
181+
182+
test('throws when destination directory is not empty', async () => {
183+
await expect(async () => {
184+
// Given
185+
const repoUrl = 'http://repoUrl'
186+
const destination = 'destination'
187+
vi.mocked(fileExists).mockResolvedValue(true)
188+
vi.mocked(isDirectory).mockResolvedValue(true)
189+
vi.mocked(glob).mockResolvedValue(['file1.txt', 'file2.txt'])
190+
191+
// When
192+
await git.downloadGitRepository({repoUrl, destination})
193+
194+
// Then
195+
}).rejects.toThrowError(/already exists and is not empty/)
196+
})
197+
198+
test('throws when destination contains only hidden files', async () => {
199+
await expect(async () => {
200+
// Given
201+
const repoUrl = 'http://repoUrl'
202+
const destination = 'destination'
203+
vi.mocked(fileExists).mockResolvedValue(true)
204+
vi.mocked(isDirectory).mockResolvedValue(true)
205+
vi.mocked(glob).mockResolvedValue(['.git', '.DS_Store'])
206+
207+
// When
208+
await git.downloadGitRepository({repoUrl, destination})
209+
210+
// Then
211+
}).rejects.toThrowError(/already exists and is not empty/)
212+
})
213+
214+
test('succeeds when destination directory is empty', async () => {
215+
// Given
216+
const repoUrl = 'http://repoUrl'
217+
const destination = 'destination'
218+
const options: any = {'--recurse-submodules': null}
219+
vi.mocked(fileExists).mockResolvedValue(true)
220+
vi.mocked(isDirectory).mockResolvedValue(true)
221+
vi.mocked(glob).mockResolvedValue([])
222+
223+
// When
224+
await git.downloadGitRepository({repoUrl, destination})
225+
226+
// Then
227+
expect(mockedClone).toHaveBeenCalledWith(repoUrl, destination, options)
228+
})
229+
230+
test('succeeds when destination does not exist', async () => {
231+
// Given
232+
const repoUrl = 'http://repoUrl'
233+
const destination = 'destination'
234+
const options: any = {'--recurse-submodules': null}
235+
vi.mocked(fileExists).mockResolvedValue(false)
236+
237+
// When
238+
await git.downloadGitRepository({repoUrl, destination})
239+
240+
// Then
241+
expect(mockedClone).toHaveBeenCalledWith(repoUrl, destination, options)
242+
})
154243
})
155244

156245
describe('initializeRepository()', () => {

packages/cli-kit/src/public/node/git.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
/* eslint-disable @typescript-eslint/no-base-to-string */
22
import {hasGit, isTerminalInteractive} from './context/local.js'
3-
import {appendFileSync, detectEOL, fileExistsSync, readFileSync, writeFileSync} from './fs.js'
3+
import {
4+
appendFileSync,
5+
detectEOL,
6+
fileExists,
7+
fileExistsSync,
8+
glob,
9+
isDirectory,
10+
readFileSync,
11+
writeFileSync,
12+
} from './fs.js'
413
import {AbortError} from './error.js'
514
import {cwd, joinPath} from './path.js'
615
import {runWithTimer} from './metadata.js'
@@ -129,6 +138,32 @@ export async function downloadGitRepository(cloneOptions: GitCloneOptions): Prom
129138
const {repoUrl, destination, progressUpdater, shallow, latestTag} = cloneOptions
130139
outputDebug(outputContent`Git-cloning repository ${repoUrl} into ${outputToken.path(destination)}...`)
131140
await ensureGitIsPresentOrAbort()
141+
142+
// Validate destination directory before attempting to clone
143+
if (await fileExists(destination)) {
144+
// Check if it's a directory
145+
if (!(await isDirectory(destination))) {
146+
throw new AbortError(
147+
outputContent`Can't clone to ${outputToken.path(destination)}`,
148+
"The path exists but isn't a directory.",
149+
)
150+
}
151+
152+
// Check if directory is empty
153+
const entries = await glob(['*', '.*'], {
154+
cwd: destination,
155+
deep: 1,
156+
onlyFiles: false,
157+
})
158+
159+
if (entries.length > 0) {
160+
throw new AbortError(
161+
outputContent`Directory ${outputToken.path(destination)} already exists and is not empty`,
162+
outputContent`Choose a different name or remove the existing directory first.`,
163+
)
164+
}
165+
}
166+
132167
const [repository, branch] = repoUrl.split('#')
133168
const options: TaskOptions = {'--recurse-submodules': null}
134169

0 commit comments

Comments
 (0)