Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ jobs:
uses: ./.github/workflows/checks.yml
secrets: inherit

# The same test suite on linux, macos and windows. Separate from checks so the
# platform-irrelevant parts of checks (GitVersion, biome, changelog drift) are
# not tripled, and so a platform failure is legible as one.
test-matrix:
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: ./.github/workflows/test-matrix.yml
secrets: inherit

detect:
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -157,7 +165,7 @@ jobs:

# --- Aggregate gate: one stable check name a branch ruleset can require.
gate:
needs: [ checks, detect, build, prepare, release-build, publish ]
needs: [ checks, test-matrix, detect, build, prepare, release-build, publish ]
if: always()
runs-on: ubuntu-24.04
steps:
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/test-matrix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Test matrix

# The workspace test suite on all three supported platforms. The runners cover
# two independent axes, so none of them is redundant: ubuntu is case-sensitive
# and unix, macos is case-insensitive and unix, windows is case-insensitive and
# not unix.
#
# checks.yml deliberately stays ubuntu-only. GitVersion, biome and the
# changelog-drift check are platform-irrelevant, and the drift check's
# `git diff --exit-code` would trip on line endings under Windows.
on:
workflow_call:

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ ubuntu-24.04, macos-14, windows-2022 ]
runs-on: ${{ matrix.os }}
steps:
- name: Keep line endings as committed
# Windows runners default core.autocrlf to true, which rewrites every
# checked-out text file. Path and byte handling is what this matrix
# exists to test, so the tree must be identical on all three.
shell: bash
run: git config --global core.autocrlf false

- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup
- run: pnpm i --frozen-lockfile

- run: pnpm run --if-present build --only
- run: pnpm run --if-present test --only
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"private": true,
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26",
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"build": "turbo run build --continue=dependencies-successful",
"test": "turbo run test --continue=dependencies-successful",
"type-check": "turbo run type-check",
"lint": "biome lint",
"format": "biome format",
Expand Down
22 changes: 22 additions & 0 deletions packages/build-clean/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Added a strict option that turns a refusal to clean into a build failure
- Added an exported error type for each failure the plugin raises, so they can be caught by type rather than by message

### Changed

- Hard links to build outputs are no longer removed
- Corrected the readme: cleaning runs on the esbuild path only

### Fixed

- Fixed built output being deleted on Windows and on case-insensitive filesystems
- Fixed the output directory being resolved against the process working directory rather than esbuild's
- Fixed every file being deleted when no build output is found in the output directory
- Fixed an unreadable output directory being treated as an empty one
- Fixed a directory outside the build being cleaned, including one on another drive or network share
- Fixed a symlink to a build output not being removed
- Fixed refusal messages naming the resolved path rather than the configured value

## [1.3.6] - 2026-06-14

### Changed
Expand Down
43 changes: 32 additions & 11 deletions packages/build-clean/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,27 +106,48 @@ This plugin cleans after the build completes, removing only unused files while k
interface Options {
/** Show detailed debug information */
debug?: boolean

/** Show verbose file-by-file processing */
verbose?: boolean

/** Actually delete files (default: false for safety) */
destructive?: boolean

/** Turn a refusal to clean into a build failure (default: false) */
strict?: boolean

/** Optional features. RemoveEmptyDirs is on by default */
features?: Partial<Record<Feature, boolean>>

/** Custom logger. When provided, debug and verbose are ignored */
logger?: ILogger
}
```

## Other Build Tools
## What gets removed

The plugin supports other tools via [unplugin](https://github.com/unjs/unplugin):
Files in the output directory are matched against the build's own outputs by asking the
filesystem whether they are the same file, rather than by comparing paths. Anything the
build did not produce is removed.

```ts
// vite.config.ts
import cleanPlugin from '@shellicar/build-clean/vite'
A symlink pointing at a build output is removed, because the build did not create it. A
hard link to a build output is kept: a hard link is not a reference to a file, it is the
file, so there is nothing to distinguish it from the name the build wrote.

export default defineConfig({
plugins: [cleanPlugin({ destructive: true })]
})
```
When none of the build's outputs can be found in the output directory, or the directory
cannot be read, nothing is removed and the reason is logged. That is deliberately not a
build failure, so an upgrade cannot start breaking builds. Set `strict: true` if you
want it to fail instead.

## Other Build Tools

Cleaning runs from esbuild's build hook, so it works with esbuild directly and with
anything that builds through esbuild, such as tsup.

The package also publishes `/vite`, `/rollup`, `/webpack`, `/rspack`, `/farm`,
`/rolldown`, `/nuxt` and `/astro` entry points through
[unplugin](https://github.com/unjs/unplugin). These register no cleanup hook, so they
currently do nothing. Use the esbuild plugin.

## Credits & Inspiration

Expand Down
11 changes: 11 additions & 0 deletions packages/build-clean/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,14 @@
{"description":"Fixed GHSA-g7r4-m6w7-qqqr in esbuild","category":"security","metadata":{"ghsa":"GHSA-g7r4-m6w7-qqqr"}}
{"description":"Updated esbuild peer dependency from ^0.27 to ^0.28","category":"changed"}
{"type":"release","version":"1.3.6","date":"2026-06-14","tag":"build-clean@1.3.6"}
{"description":"Fixed built output being deleted on Windows and on case-insensitive filesystems","category":"fixed"}
{"description":"Fixed the output directory being resolved against the process working directory rather than esbuild's","category":"fixed"}
{"description":"Fixed every file being deleted when no build output is found in the output directory","category":"fixed"}
{"description":"Fixed an unreadable output directory being treated as an empty one","category":"fixed"}
{"description":"Fixed a directory outside the build being cleaned, including one on another drive or network share","category":"fixed"}
{"description":"Fixed a symlink to a build output not being removed","category":"fixed"}
{"description":"Fixed refusal messages naming the resolved path rather than the configured value","category":"fixed"}
{"description":"Added a strict option that turns a refusal to clean into a build failure","category":"added"}
{"description":"Hard links to build outputs are no longer removed","category":"changed"}
{"description":"Corrected the readme: cleaning runs on the esbuild path only","category":"changed"}
{"description":"Added an exported error type for each failure the plugin raises, so they can be caught by type rather than by message","category":"added"}
1 change: 1 addition & 0 deletions packages/build-clean/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
],
"scripts": {
"build": "tsup",
"test": "vitest run",
"type-check": "tsc -p tsconfig.check.json",
"watch": "tsup --watch"
},
Expand Down
71 changes: 62 additions & 9 deletions packages/build-clean/src/core/cleanUnusedFiles.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import { relative } from 'node:path';
import { relative, resolve } from 'node:path';
import { Feature } from '../enums';
import { CleanRefusedError } from '../errors/CleanRefusedError';
import { deleteFile } from './deleteFile';
import { fileIdentity } from './fileIdentity';
import { getAllFiles } from './getAllFiles';
import { removeEmptyDirs } from './removeEmptyDirs';
import type { ResolvedOptions } from './types';
import { validateOutDir } from './validateOutDir';

export async function cleanUnusedFiles(outDir: string, builtFiles: Set<string>, options: ResolvedOptions): Promise<void> {
// Nothing is deleted when the plugin cannot trust what it is looking at. The
// refusal is loud but does not fail the build unless the caller asked for that,
// because a plugin that starts breaking builds on upgrade is its own incident.
const refuse = (reason: string, options: ResolvedOptions, cause?: unknown): void => {
const refusal = new CleanRefusedError(reason, { cause });

// Passing an absent cause still counts as an argument, and the logger spreads
// its arguments, so the word undefined would reach the user.
if (cause === undefined) {
options.logger.error(refusal.message);
} else {
options.logger.error(refusal.message, cause);
}

if (options.strict) {
throw refusal;
}
};

// baseDir is esbuild's working directory, which is what its metafile paths are
// relative to. It is not always the process working directory.
export async function cleanUnusedFiles(outDir: string, builtFiles: Set<string>, baseDir: string, options: ResolvedOptions): Promise<void> {
const { logger } = options;
validateOutDir(outDir, logger);
const resolvedOutDir = validateOutDir(outDir, baseDir, logger);

try {
logger.debug(`Starting cleanup of directory: "${outDir}"`);
logger.debug(`Starting cleanup of directory: "${resolvedOutDir}"`);
logger.debug(`Built files count: ${builtFiles.size}`);

const existingFiles = await getAllFiles(outDir, logger);
let existingFiles: string[];
try {
existingFiles = await getAllFiles(resolvedOutDir, logger);
} catch (error) {
return refuse(`Could not read the output directory: "${resolvedOutDir}"`, options, error);
}
logger.debug(`Existing files count: ${existingFiles.length}`);

if (existingFiles.length === 0 && builtFiles.size > 0) {
Expand All @@ -24,13 +52,35 @@ export async function cleanUnusedFiles(outDir: string, builtFiles: Set<string>,

logger.info(`Processing ${existingFiles.length} existing files vs ${builtFiles.size} built files`);

const builtIdentities = new Set<string>();
for (const builtFile of builtFiles) {
const identity = await fileIdentity(resolve(baseDir, builtFile));
if (identity !== undefined) {
builtIdentities.add(identity);
}
}
logger.debug(`Resolved ${builtIdentities.size} of ${builtFiles.size} built files on disk`);

// Every output the build reported is missing from where it should be, so
// this directory is not the one that was built into. Deleting what does not
// match would take all of it.
//
// Only when there was something to find. A build that produced nothing
// matches nothing whatever directory it is pointed at, so zero matches says
// nothing about the directory, and everything present is from an earlier
// build and due to be removed.
if (builtFiles.size > 0 && builtIdentities.size === 0) {
return refuse(`None of the ${builtFiles.size} files the build reported were found under "${resolvedOutDir}"`, options);
}

const filesToDelete: string[] = [];

for (const file of existingFiles) {
const relativePath = relative(process.cwd(), file);
const relativePath = relative(baseDir, file);
logger.verbose(`Checking file: "${relativePath}"`);

if (!builtFiles.has(relativePath)) {
const identity = await fileIdentity(file);
if (identity === undefined || !builtIdentities.has(identity)) {
filesToDelete.push(file);
logger.verbose(`Marked for deletion: "${relativePath}"`);
} else {
Expand All @@ -47,7 +97,7 @@ export async function cleanUnusedFiles(outDir: string, builtFiles: Set<string>,

let deletedCount = 0;
for (const file of filesToDelete) {
const relativePath = relative(process.cwd(), file);
const relativePath = relative(baseDir, file);

logger.info(`Deleting: "${relativePath}"`);
if (options.destructive) {
Expand All @@ -64,9 +114,12 @@ export async function cleanUnusedFiles(outDir: string, builtFiles: Set<string>,
}

if (options.features[Feature.RemoveEmptyDirs]) {
await removeEmptyDirs(outDir, options);
await removeEmptyDirs(resolvedOutDir, options);
}
} catch (error) {
if (error instanceof CleanRefusedError) {
throw error;
}
logger.error('Error during cleanup:', error);
throw error;
}
Expand Down
1 change: 1 addition & 0 deletions packages/build-clean/src/core/defaultOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const defaultOptions = {
debug: false,
verbose: false,
destructive: false,
strict: false,
features: {
[Feature.RemoveEmptyDirs]: true,
},
Expand Down
18 changes: 18 additions & 0 deletions packages/build-clean/src/core/fileIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { lstat } from 'node:fs/promises';

// Asks the filesystem whether two paths are the same file, rather than deciding
// it from the strings. That is what makes the match correct on a case-folding
// filesystem and independent of which separator each side happens to use.
//
// lstat, not stat: following a symlink would give it the identity of whatever it
// points at, so a link sitting in the output directory would be mistaken for the
// file it targets and kept. A hard link is a different matter and cannot be told
// apart this way, because it is not a reference to a file, it is the file.
export const fileIdentity = async (path: string): Promise<string | undefined> => {
try {
const stats = await lstat(path);
return `${stats.dev}:${stats.ino}`;
} catch {
return undefined;
}
};
11 changes: 9 additions & 2 deletions packages/build-clean/src/core/getAllFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { ILogger } from '../types';

const isNotFound = (error: unknown): boolean => typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';

export async function getAllFiles(dir: string, logger: ILogger): Promise<string[]> {
const files: string[] = [];

Expand All @@ -22,8 +24,13 @@ export async function getAllFiles(dir: string, logger: ILogger): Promise<string[
}
}
} catch (error) {
// Directory might not exist yet
logger.debug(`Could not read directory "${dir}":`, error);
// A directory that is not there yet is the ordinary first-build case and
// means no files. Anything else means the directory cannot be read, which is
// not the same thing and must not be reported as an empty one.
if (!isNotFound(error)) {
throw error;
}
logger.debug(`Directory does not exist yet: "${dir}"`);
}

logger.verbose(`Total files found in "${dir}": ${files.length}`);
Expand Down
10 changes: 10 additions & 0 deletions packages/build-clean/src/core/isOutsideBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import path, { type posix } from 'node:path';

// Outside means either the route climbs out of the base, or there is no route at
// all. path.relative signals the second case by returning an absolute path,
// which on Windows is what a different drive letter or a UNC share produces.
// `paths` is the semantics the two arguments are written in.
export const isOutsideBase = (baseDir: string, candidate: string, paths: typeof posix = path): boolean => {
const relativePath = paths.relative(baseDir, candidate);
return relativePath === '..' || relativePath.startsWith(`..${paths.sep}`) || paths.isAbsolute(relativePath);
};
Loading
Loading