Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ Filter by age:
docklean --images --older-than 7d
```

Clean top 10 largest resources:

```bash
docklean --images --top 10
```

Clean until 5GB is reclaimed:

```bash
docklean --images --limit-space 5GB
```

Combine filters:

```bash
docklean --images --older-than 30d --top 5
```

## Flags

- `--containers` Clean stopped/exited containers
Expand All @@ -69,6 +87,8 @@ docklean --images --older-than 7d
- `--dangling` Dangling images + stopped containers + unused volumes
- `--all` All unused resources
- `--older-than <duration>` Only clean items older than `m/h/d/w`
- `--limit-space <size>` Clean until specified space is reclaimed (e.g., `5GB`, `500MB`)
- `--top <number>` Select top N largest resources
Comment on lines +90 to +91
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

Consider clarifying in the README that when using --top or --limit-space with multiple resource types (e.g., --containers --images --top 10), the filter applies independently to each resource type. For example, --top 10 with both containers and images would select up to 10 containers AND up to 10 images (20 items total), not 10 items across all types.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added clarification in commit ed4605f. The README now explains that when using --top or --limit-space with multiple resource types, the filter applies independently to each type (e.g., --top 10 with containers and images selects 10 of each, not 10 total).

- `--dry-run` Print what would be removed
- `--force` Skip confirmation prompt
- `--yes` Alias for `--force`
Expand All @@ -77,6 +97,10 @@ docklean --images --older-than 7d
- `--verbose` More verbose output
- `--no-color` Disable colored output

**Note:** `--limit-space` and `--top` cannot be used together, and they only apply to containers and images. Docker volumes and networks do not report size information, so they are not affected by these flags.

When using `--top` or `--limit-space` with multiple resource types (e.g., `--containers --images --top 10`), the filter applies independently to each resource type. For example, `--top 10` with both containers and images would select up to 10 containers AND up to 10 images (20 items total), not 10 items across all types.

## Exit Codes

- `0` Success
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,37 @@ import { CliOptions } from "./types";
export interface ParsedArgs {
options: CliOptions;
olderThanMs?: number;
limitSpaceBytes?: number;
selectedResources: Array<keyof Pick<CliOptions, "containers" | "images" | "volumes" | "networks" | "cache">>;
}

const resourceFlags = ["containers", "images", "volumes", "networks", "cache"] as const;
const mutableResourceFlags = [...resourceFlags];

export function parseSizeString(sizeStr: string): number {
const trimmed = sizeStr.trim();
const match = trimmed.match(/^([0-9.]+)\s*(B|KB|MB|GB|TB)?$/i);
if (!match) {
throw new Error("Invalid size format. Use formats like: 5GB, 500MB, 1.5GB");
}

const value = Number(match[1]);
if (isNaN(value) || value < 0) {
throw new Error("Invalid size value: must be a non-negative number");
}

const unit = (match[2] || "B").toLowerCase();
const multipliers: Record<string, number> = {
b: 1,
kb: 1000,
mb: 1000 ** 2,
gb: 1000 ** 3,
tb: 1000 ** 4
};

return Math.round(value * (multipliers[unit] ?? 1));
}

export function parseArgs(argv: string[]): ParsedArgs {
const program = new Command();

Expand All @@ -25,6 +50,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
.option("--dangling", "Clean dangling images, stopped containers, unused volumes")
.option("--all", "Clean all unused resources")
.option("--older-than <duration>", "Only clean resources older than m/h/d/w")
.option("--limit-space <size>", "Clean until specified space is reclaimed (e.g., 5GB, 500MB)")
.option("--top <number>", "Select top N largest resources", parseInt)
.option("-f, --force", "Skip confirmation prompt")
.option("-y, --yes", "Alias for --force")
.option("--dry-run", "Print what would be removed")
Expand All @@ -41,6 +68,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
throw new Error("Use either --quiet or --verbose, not both.");
}

if (raw.limitSpace && raw.top) {
throw new Error("Use either --limit-space or --top, not both.");
}

const options: CliOptions = {
containers: Boolean(raw.containers),
images: Boolean(raw.images),
Expand All @@ -52,6 +83,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
force: Boolean(raw.force || raw.yes),
dryRun: Boolean(raw.dryRun),
olderThan: raw.olderThan,
limitSpace: raw.limitSpace,
top: raw.top,
verbose: Boolean(raw.verbose),
quiet: Boolean(raw.quiet),
json: Boolean(raw.json),
Expand All @@ -71,6 +104,26 @@ export function parseArgs(argv: string[]): ParsedArgs {
throw new Error("Invalid --older-than value. Use m/h/d/w like 7d or 12h.");
}

// Validate and parse limitSpace
let limitSpaceBytes: number | undefined = undefined;
if (options.limitSpace) {
try {
limitSpaceBytes = parseSizeString(options.limitSpace);
if (limitSpaceBytes <= 0) {
throw new Error("Size must be greater than 0");
}
} catch (error) {
throw new Error(`Invalid --limit-space value: ${error instanceof Error ? error.message : String(error)}`);
}
}

// Validate top flag
if (options.top !== undefined) {
if (isNaN(options.top) || options.top <= 0 || !Number.isInteger(options.top)) {
throw new Error("Invalid --top value. Must be a positive integer.");
}
}

let selectedFlags = mutableResourceFlags.filter((flag) => options[flag]);
if (options.all) {
selectedFlags = mutableResourceFlags;
Expand All @@ -79,6 +132,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
return {
options,
olderThanMs,
limitSpaceBytes,
selectedResources: selectedFlags
};
}
71 changes: 62 additions & 9 deletions src/clean.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { dockerPrune, filterUntilTimestamp, parseDockerSize } from "./docker";
import { CleanResult, ResourceType } from "./types";
import {
dockerPrune,
dockerRemoveContainers,
dockerRemoveImages,
dockerRemoveVolumes,
dockerRemoveNetworks,
filterUntilTimestamp,
parseDockerSize
} from "./docker";
import { CleanResult, ResourceType, SelectedResources } from "./types";

interface CleanOptions {
resources: ResourceType[];
olderThanMs?: number;
dryRun: boolean;
includeAllImages: boolean;
expectedCounts: Record<ResourceType, number>;
selectedIds?: SelectedResources;
estimatedBytes?: Record<ResourceType, number>;
}

export async function cleanResources(options: CleanOptions): Promise<CleanResult> {
Expand All @@ -32,15 +42,58 @@ export async function cleanResources(options: CleanOptions): Promise<CleanResult
const filterUntil = filterUntilTimestamp(options.olderThanMs);
let reclaimedBytes = 0;

// When size filters are active, we have selectedIds and must delete by ID
const useBulkPrune = !options.selectedIds;

for (const type of options.resources) {
try {
const output = await dockerPrune(
type,
filterUntil,
options.includeAllImages && type === "images"
);
removed[type] = parsePrunedCount(output) || options.expectedCounts[type];
reclaimedBytes += parseReclaimedBytes(output);
let output = "";

if (useBulkPrune) {
// Use bulk prune for age-only filtering
output = await dockerPrune(
type,
filterUntil,
options.includeAllImages && type === "images"
);
removed[type] = parsePrunedCount(output) || options.expectedCounts[type];
reclaimedBytes += parseReclaimedBytes(output);
} else {
// Use ID-based deletion for size filtering
// Cache doesn't have individual items, so handle it separately
if (type === "cache") {
output = await dockerPrune(type, filterUntil, false);
removed[type] = parsePrunedCount(output) || options.expectedCounts[type];
reclaimedBytes += parseReclaimedBytes(output);
} else {
const ids = options.selectedIds![type];
if (ids && ids.length > 0) {
switch (type) {
case "containers":
await dockerRemoveContainers(ids);
removed[type] = ids.length;
break;
case "images":
await dockerRemoveImages(ids);
removed[type] = ids.length;
break;
case "volumes":
await dockerRemoveVolumes(ids);
removed[type] = ids.length;
break;
case "networks":
await dockerRemoveNetworks(ids);
removed[type] = ids.length;
break;
}
// For ID-based deletion, use the estimated bytes from scan phase
// since docker rm doesn't report space
if (options.estimatedBytes) {
reclaimedBytes += options.estimatedBytes[type] || 0;
}
}
}
}
} catch (error) {
failures[type].push(error instanceof Error ? error.message : String(error));
}
Expand Down
24 changes: 24 additions & 0 deletions src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,27 @@ export function toExitCode(code?: number): ExitCode {
if (!code || code === 0) return 0;
return 4;
}

export async function dockerRemoveContainers(containerIds: string[]): Promise<string> {
if (containerIds.length === 0) return "";
const { stdout } = await execDocker(["container", "rm", "-f", ...containerIds]);
return stdout;
}

export async function dockerRemoveImages(imageIds: string[]): Promise<string> {
if (imageIds.length === 0) return "";
const { stdout } = await execDocker(["image", "rm", "-f", ...imageIds]);
return stdout;
}

export async function dockerRemoveVolumes(volumeNames: string[]): Promise<string> {
if (volumeNames.length === 0) return "";
const { stdout } = await execDocker(["volume", "rm", "-f", ...volumeNames]);
return stdout;
}

export async function dockerRemoveNetworks(networkIds: string[]): Promise<string> {
if (networkIds.length === 0) return "";
const { stdout } = await execDocker(["network", "rm", ...networkIds]);
return stdout;
}
41 changes: 38 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function summarizeCounts(resources: ResourceType[], totalItems: number): string
}

async function run(): Promise<number> {
const { options, olderThanMs } = parseArgs(process.argv);
const { options, olderThanMs, limitSpaceBytes } = parseArgs(process.argv);
setColorEnabled(!options.noColor);

const dockerCheck = await checkDocker();
Expand All @@ -64,7 +64,9 @@ async function run(): Promise<number> {
includeNetworks: resources.includes("networks"),
includeCache: resources.includes("cache"),
includeAllImages,
olderThanMs
olderThanMs,
top: options.top,
limitSpaceBytes
});
Comment on lines +67 to 70
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

Critical bug: When using --top or --limit-space flags, the scan phase (lines 67-70) correctly filters resources by size, but the clean phase (around line 145, not shown in diff) uses docker prune commands that only filter by age. This means the actual cleanup will remove ALL resources matching the age filter, not just the size-filtered subset that the user confirmed. For example, if a user selects --top 5 to clean only the 5 largest images, the cleanup will actually remove all matching images, not just the 5 shown.

The implementation needs to be modified to either:

  1. Remove items individually by ID instead of using bulk prune commands when size filters are active, or
  2. Store the IDs of selected items during scan and pass them to clean for targeted removal

This is a critical issue that could cause unexpected data loss.

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback and
also apply changes based on this feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit ed4605f. When size filters (--top or --limit-space) are active, the cleanup now uses ID-based deletion (docker container/image/volume/network rm) instead of bulk prune commands. This ensures only the size-filtered resources shown to the user are deleted, preventing unexpected data loss.

scanSpinner?.succeed("Scan complete");

Expand Down Expand Up @@ -140,12 +142,45 @@ async function run(): Promise<number> {
cache: 0
} as Record<ResourceType, number>);

// Extract selected IDs when size filters are active
const hasSizeFilters = options.top !== undefined || limitSpaceBytes !== undefined;
let selectedIds = undefined;
let estimatedBytes = undefined;

if (hasSizeFilters) {
selectedIds = scanResult.summaries.reduce((acc, summary) => {
// Only collect IDs for resource types that have them (not cache)
if (summary.type !== "cache") {
acc[summary.type] = summary.items.map(item => item.id);
}
return acc;
}, {
containers: [],
images: [],
volumes: [],
networks: []
} as import("./types").SelectedResources);

estimatedBytes = scanResult.summaries.reduce((acc, summary) => {
acc[summary.type] = summary.reclaimableBytes || 0;
return acc;
}, {
containers: 0,
images: 0,
volumes: 0,
networks: 0,
cache: 0
} as Record<ResourceType, number>);
}

const cleanResult = await cleanResources({
resources,
olderThanMs,
dryRun: options.dryRun,
includeAllImages,
expectedCounts
expectedCounts,
selectedIds,
estimatedBytes
});
cleanSpinner?.succeed("Cleanup completed");

Expand Down
Loading