Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI uses bun-version: latest, which can make builds non-deterministic and introduce sudden breakages when Bun releases. Consider pinning to a specific Bun version (or at least the minimum supported in package.json engines) and optionally adding a separate scheduled job for latest if you want early warning.

Suggested change
bun-version: latest
bun-version: '1.0.0'

Copilot uses AI. Check for mistakes.

- name: Install dependencies
run: bun install

- name: Type check
run: bun run check

- name: Build
run: bun run build

- name: Smoke test - help
run: bun run src/cli.ts --help

- name: Smoke test - dry-run (Unix)
if: runner.os != 'Windows'
run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors

- name: Smoke test - dry-run (Windows)
if: runner.os == 'Windows'
run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors
shell: pwsh
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,22 @@ BunKill scans large directory trees, calculates folder sizes, and lets you delet

## Requirements

- Bun is required at runtime
- macOS is the only platform tested so far
- [Bun](https://bun.sh) runtime is required
- Supported platforms: **macOS**, **Linux**, **Windows 10/11**
- Windows: requires [Windows Terminal](https://aka.ms/terminal) for best interactive UI experience

Install Bun if needed:
**Install Bun:**

macOS / Linux:
```bash
curl -fsSL https://bun.sh/install | bash
```

Windows (PowerShell):
```powershell
powershell -c "irm bun.sh/install.ps1 | iex"
```

## Install

```bash
Expand All @@ -56,9 +63,12 @@ bun install -g bunkill
# interactive scan in current directory
bunkill

# scan a specific directory
# scan a specific directory (macOS / Linux)
bunkill --dir ~/Projects

# scan a specific directory (Windows)
bunkill --dir "C:\Users\YourName\Projects"

# preview only
bunkill --dir ~/Projects --dry-run

Expand Down Expand Up @@ -101,13 +111,14 @@ Search filters the already loaded list, so you can quickly narrow large result s

## Platform status

- macOS: tested
- Linux: not tested yet
- Windows: not tested yet

Linux and Windows may work, but they have not been validated in this repo yet.
| Platform | Status |
|---|---|
| macOS | ✅ Tested |
| Linux | ⚠️ Not tested yet |
| Windows 10/11 | ✅ Tested (Windows Terminal recommended) |
Comment on lines +114 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align Linux platform status with the new CI matrix.

Line 117 currently says Linux is “Not tested yet”, but this PR adds Linux CI runs. Please update wording to avoid contradictory platform claims.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 114 - 118, Update the Linux status row in the README
table that currently reads "Linux | ⚠️ Not tested yet" to reflect the new CI
coverage introduced by this PR; replace that cell with a concise phrase such as
"✅ Tested (CI)" or "✅ Tested (CI runs)" so the Linux entry matches the CI matrix
and avoids contradiction with the added CI runs.

Comment on lines +114 to +118
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README now claims Linux is "⚠️ Not tested yet", but this PR adds a CI job that runs typecheck/build and CLI smoke tests on ubuntu-latest. Either update the table to reflect that Linux is tested (at least via CI smoke tests) or adjust the workflow/wording so they’re consistent.

Copilot uses AI. Check for mistakes.

Contributions for Linux and Windows testing or fixes are welcome.
> **Windows note:** Interactive mode requires a terminal that supports ANSI escape codes and raw mode.
> [Windows Terminal](https://aka.ms/terminal) works well. The legacy `cmd.exe` prompt is not supported.

## Performance

Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { stat } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { filesize } from "filesize";
import { APP_CONFIG } from "./config.ts";
import { deleteModules, scan as scanEngine } from "./scanner.ts";
import { deleteModules, normalizeProjectPath, scan as scanEngine } from "./scanner.ts";
import type { NodeModule, ScanOptions } from "./types.ts";

const LOGO = `
Expand Down Expand Up @@ -558,7 +558,7 @@ class BunKill {
}

this.pendingUiMeta.add(module.path);
const projectPath = module.path.replace(/\/node_modules$/, "");
const projectPath = normalizeProjectPath(module.path);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeProjectPath(module.path) uses the default target (node_modules). If the user runs the CLI with --target <name>, module.path will end with that custom target, and this call won’t strip it—so git -C will run inside the target directory instead of the project root (often causing git status to fail / mark as non-repo). Consider persisting the scan target on the BunKill instance and passing it here (or storing the derived project root on NodeModule).

Suggested change
const projectPath = normalizeProjectPath(module.path);
const projectPath =
(module as any).projectRoot ??
normalizeProjectPath(module.path);

Copilot uses AI. Check for mistakes.

try {
const result = await Bun.$`git -C ${projectPath} status --short --branch`.quiet().nothrow();
Expand Down
102 changes: 59 additions & 43 deletions src/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdir } from "node:fs/promises";
import { readdir, rm } from "node:fs/promises";
import { basename, join } from "node:path";
import { APP_CONFIG, SCAN_PATHS } from "./config.ts";
import type { DeleteResult, NodeModule, ScanOptions, ScanResult } from "./types.ts";
Expand Down Expand Up @@ -32,31 +32,49 @@ class Semaphore {
const PERMISSION_ERROR_CODES = new Set(SCAN_PATHS.permissionErrorCodes);

function shouldSkip(dirPath: string): boolean {
const np = normalizeSep(dirPath);
const isAllowedCache = SCAN_PATHS.allowCachePatterns.some(
(p) =>
dirPath.includes(p) &&
!SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)),
np.includes(p) &&
!SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)),
);
if (isAllowedCache) return false;
if (dirPath.includes(".npm/_npx")) return false;
if (np.includes(".npm/_npx")) return false;

return SCAN_PATHS.systemSkipPatterns.some(
(p) =>
dirPath.includes(p) ||
dirPath.toLowerCase().includes(p.toLowerCase()),
np.includes(p) ||
np.toLowerCase().includes(p.toLowerCase()),
);
}

const IS_WINDOWS = process.platform === "win32";

function normalizeSep(p: string): string {
return IS_WINDOWS ? p.replaceAll("\\", "/") : p;
}

export function normalizeProjectPath(nmPath: string, target = "node_modules"): string {
const normalized = normalizeSep(nmPath);
const suffix = "/" + target.replace(/\/+$/, "");
return normalized.endsWith(suffix) ? normalized.slice(0, -suffix.length) : normalized;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function isWithinRoot(path: string, root: string): boolean {
return path === root || path.startsWith(`${root}/`);
const np = normalizeSep(path);
const nr = normalizeSep(root);
return np === nr || np.startsWith(`${nr}/`);
}

function hasHiddenPathSegment(dirPath: string, root: string): boolean {
const relativePath = dirPath.startsWith(`${root}/`)
? dirPath.slice(root.length + 1)
: dirPath === root
const np = normalizeSep(dirPath);
const nr = normalizeSep(root);

const relativePath = np.startsWith(`${nr}/`)
? np.slice(nr.length + 1)
: np === nr
? ""
: dirPath;
: np;

if (!relativePath) {
return false;
Expand Down Expand Up @@ -84,27 +102,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo
return true;
}

const lowerRoot = matchingRoot.toLowerCase();
const lowerPath = dirPath.toLowerCase();
const np = normalizeSep(dirPath);
const nr = normalizeSep(matchingRoot);
const lowerNp = np.toLowerCase();
const lowerNr = nr.toLowerCase();

const isAllowedCache = SCAN_PATHS.allowCachePatterns.some(
(pattern) =>
dirPath.includes(pattern) &&
!SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)),
np.includes(pattern) &&
!SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)),
);
if (isAllowedCache || dirPath.includes(".npm/_npx")) {
if (isAllowedCache || np.includes(".npm/_npx")) {
return false;
}

return SCAN_PATHS.systemSkipPatterns.some((pattern) => {
const matchesPattern =
dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase());
np.includes(pattern) || lowerNp.includes(pattern.toLowerCase());
if (!matchesPattern) {
return false;
}

const rootIncludesPattern =
matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase());
nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase());
return !rootIncludesPattern;
});
};
Expand Down Expand Up @@ -153,25 +173,27 @@ async function readPackageMetadata(projectPath: string): Promise<{
}

async function getDirectorySize(dirPath: string): Promise<number> {
try {
const proc = Bun.spawn({
cmd: ["du", "-sk", dirPath],
stdout: "pipe",
stderr: "ignore",
});
const output = await (new Response(proc.stdout) as globalThis.Response).text();
if (await proc.exited === 0) {
const match = output.match(/^(\d+)/);
if (match?.[1]) return parseInt(match[1], 10) * 1024;
if (!IS_WINDOWS) {
try {
const proc = Bun.spawn({
cmd: ["du", "-sk", dirPath],
stdout: "pipe",
stderr: "ignore",
});
const output = await (new Response(proc.stdout) as globalThis.Response).text();
if (await proc.exited === 0) {
const match = output.match(/^(\d+)/);
if (match?.[1]) return parseInt(match[1], 10) * 1024;
}
} catch {
/* ignore */
}
} catch {
/* ignore */
}

let total = 0;
try {
const glob = new Bun.Glob("**/*");
for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true })) {
for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true, dot: true })) {
try {
const s = await Bun.file(join(dirPath, file)).stat();
total += s.size;
Expand Down Expand Up @@ -242,7 +264,10 @@ async function discoverNodeModulesWithFs(
}

if (entry.name === options.target) {
if (fullPath.split("/node_modules").length > 2) {
// Detect nesting: check if the normalized path contains /target/ as a segment
const normalizedFull = normalizeSep(fullPath);
const segmentMarker = "/" + options.target + "/";
if (normalizedFull.includes(segmentMarker)) {
continue;
}
hits.push(fullPath);
Expand Down Expand Up @@ -325,7 +350,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
let mod: NodeModule | null = null;

await metaSemaphore.acquire();
const projectPath = nmPath.replace(/\/node_modules$/, "");
const projectPath = normalizeProjectPath(nmPath, options.target);
try {
mod = await processModuleMeta(nmPath, projectPath);
} finally {
Expand Down Expand Up @@ -423,16 +448,7 @@ export async function deleteModules(

for (const mod of modules) {
try {
const proc = Bun.spawn({
cmd: ["rm", "-rf", mod.path],
stdout: "ignore",
stderr: "ignore",
});
const ok = await proc.exited === 0;
if (!ok) {
failedPaths.push(mod.path);
continue;
}
await rm(mod.path, { recursive: true, force: true });
deleted++;
freed += mod.size;
deletedPaths.push(mod.path);
Expand Down
Loading