Skip to content

Commit d057a26

Browse files
authored
fix(cleanup): improve empty directory cleanup logic to handle nested structures (#370)
1 parent 8a555a7 commit d057a26

1 file changed

Lines changed: 34 additions & 15 deletions

File tree

packages/workshop-utils/src/git.server.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ async function cleanupEmptyExerciseDirectories(cwd: string) {
3131

3232
let deletedCount = 0
3333

34+
// Determine which directories contain any files (tracked or untracked), and
35+
// which are "fileless" (contain only subdirectories or are empty).
36+
const hasFilesMap = new Map<string, boolean>()
3437
for (const dir of directories) {
3538
if (dir === 'exercises') continue // Skip the root exercises directory
3639

37-
// Check if directory has any files (excluding gitignored files)
3840
const [trackedFiles, untrackedFiles] = await Promise.all([
3941
execa('git', ['ls-files', dir], { cwd, reject: false }).catch(() => ({
4042
stdout: '',
@@ -45,26 +47,43 @@ async function cleanupEmptyExerciseDirectories(cwd: string) {
4547
}).catch(() => ({ stdout: '' })),
4648
])
4749

48-
// Fix: Use proper boolean logic to check if both outputs are empty
4950
const hasTrackedFiles = trackedFiles.stdout.trim().length > 0
5051
const hasUntrackedFiles = untrackedFiles.stdout.trim().length > 0
51-
const hasFiles = hasTrackedFiles || hasUntrackedFiles
52-
53-
if (!hasFiles) {
54-
console.log(` Deleting empty directory: ${dir}`)
55-
try {
56-
// Use fs.rmdir instead of shell command to avoid shell injection
57-
await fs.rmdir(path.join(cwd, dir))
58-
deletedCount++
59-
} catch {
60-
// Directory might not be empty or might not exist, which is fine
61-
// We'll just continue with the next directory
62-
}
52+
hasFilesMap.set(dir, hasTrackedFiles || hasUntrackedFiles)
53+
}
54+
55+
// Build a set of directories that have no files anywhere in their subtree
56+
const emptyDirs = directories.filter(
57+
(dir) => dir !== 'exercises' && hasFilesMap.get(dir) === false,
58+
)
59+
const emptySet = new Set(emptyDirs)
60+
61+
// From the empty directories, pick only the top-most ones (those that do not
62+
// have an ancestor also in the empty set). Deleting these recursively is
63+
// faster and removes entire empty trees in one go.
64+
const topLevelEmptyDirs = emptyDirs.filter((dir) => {
65+
let parent = path.posix.dirname(dir)
66+
while (parent && parent !== '.' && parent !== 'exercises') {
67+
if (emptySet.has(parent)) return false
68+
parent = path.posix.dirname(parent)
69+
}
70+
return true
71+
})
72+
73+
for (const dir of topLevelEmptyDirs) {
74+
console.log(` Deleting empty directory tree: ${dir}`)
75+
try {
76+
// Recursively remove the directory tree. This is safe because we've
77+
// confirmed there are no files (tracked or untracked) anywhere within.
78+
await fs.rm(path.join(cwd, dir), { recursive: true, force: true })
79+
deletedCount++
80+
} catch {
81+
// Directory might not exist due to race conditions; continue.
6382
}
6483
}
6584

6685
if (deletedCount > 0) {
67-
console.log(` Deleted ${deletedCount} empty directories.`)
86+
console.log(` Deleted ${deletedCount} empty directory tree(s).`)
6887
} else {
6988
console.log(' No empty directories found.')
7089
}

0 commit comments

Comments
 (0)