From 9518187963f2ad2d1f133c21154225daafa1efa9 Mon Sep 17 00:00:00 2001 From: rohanshrma222 Date: Fri, 28 Aug 2026 18:46:24 +0530 Subject: [PATCH 1/4] feat: add nanotune clean and report fused/ cache disk usage --- docs/commands/clean.md | 45 ++++++++++ docs/commands/export.md | 8 +- docs/commands/index.md | 1 + docs/commands/status.md | 1 + src/cli.tsx | 27 +++++- src/commands/clean.tsx | 155 +++++++++++++++++++++++++++++++++ src/commands/commands.spec.tsx | 97 ++++++++++++++++++++- src/commands/export.spec.ts | 18 ++++ src/commands/export.tsx | 49 ++++++++++- src/commands/status.tsx | 24 +++-- src/lib/config.ts | 32 +++++++ 11 files changed, 443 insertions(+), 14 deletions(-) create mode 100644 docs/commands/clean.md create mode 100644 src/commands/clean.tsx create mode 100644 src/commands/export.spec.ts diff --git a/docs/commands/clean.md b/docs/commands/clean.md new file mode 100644 index 0000000..4c86c6a --- /dev/null +++ b/docs/commands/clean.md @@ -0,0 +1,45 @@ +--- +title: "nanotune clean" +description: "Remove the cached fused model to reclaim disk space" +sidebar_order: 9 +--- + +# nanotune clean + +Remove the fused model cache at `.nanotune/models/fused/`. + +`nanotune export` keeps a full-precision copy of the fused model around after export so that `nanotune export --skip-fuse` can reuse it for a different quantization without redoing the fusion step. That directory can be multiple gigabytes — `nanotune clean` deletes it to reclaim the space. + +## Usage + +```bash +nanotune clean +``` + +## Options + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Skip the confirmation prompt (for scripts and CI) | + +## Examples + +```bash +# Interactively confirm before removing the fused model cache +nanotune clean + +# Remove it without prompting +nanotune clean --yes +``` + +## What It Does + +1. Checks `.nanotune/models/fused/` for a cached fused model. +2. If found, shows its size and asks for confirmation (unless `--yes` is passed). +3. Deletes the directory and reports how much space was freed. + +If there's nothing to clean, it says so and exits without changes. Your exported `.gguf` files are never touched — only the intermediate fused model is removed. The next `nanotune export` will simply re-fuse the adapter. + +## See Also + +- [`nanotune export`](export.md) — See "Fused Model Cache" for why this directory exists diff --git a/docs/commands/export.md b/docs/commands/export.md index 5fc188f..06e89a0 100644 --- a/docs/commands/export.md +++ b/docs/commands/export.md @@ -20,7 +20,7 @@ nanotune export |------|-------------| | `-q, --quantization ` | Quantization type: `f16`, `q8_0`, `q4_k_m`, `q4_k_s` | | `-o, --output ` | Output filename | -| `--skip-fuse` | Skip adapter fusion if already fused | +| `--skip-fuse` | Skip adapter fusion — requires a `fused/` cache from a previous export | ## Examples @@ -46,6 +46,12 @@ nanotune export --skip-fuse Pre-built llama.cpp binaries are downloaded automatically — no compilation needed. +## Fused Model Cache + +Fusing the LoRA adapter into the base model produces a full-precision copy, which is kept at `.nanotune/models/fused/` after export finishes — it's not deleted. Keeping it around lets `nanotune export --skip-fuse` reuse it to produce a different quantization without redoing the fusion step. + +That directory can be multiple gigabytes. Its size is shown when export completes and in `nanotune status`. Run [`nanotune clean`](clean.md) to remove it and reclaim the space — the next export will simply re-fuse the adapter. + ## Quantization Types | Type | Description | diff --git a/docs/commands/index.md b/docs/commands/index.md index 1b02e0e..916d360 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -12,6 +12,7 @@ A complete reference for all Nanotune CLI commands. |---------|-------------| | [`nanotune init`](init.md) | Initialize a new fine-tuning project | | [`nanotune status`](status.md) | Show current project status | +| [`nanotune clean`](clean.md) | Remove the cached fused model to reclaim disk space | | [`nanotune data add`](data.md) | Add training examples interactively | | [`nanotune data import`](data.md) | Import training data from a file | | [`nanotune data list`](data.md) | View and manage training data | diff --git a/docs/commands/status.md b/docs/commands/status.md index e2c00a7..e6efef5 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -19,4 +19,5 @@ nanotune status - **Training data** — Number of examples loaded - **Training progress** — Current state and last training run - **Exports** — Available GGUF files +- **Fused model cache** — Disk space used by the retained `fused/` model, if present - **Benchmark results** — Latest benchmark summary diff --git a/src/cli.tsx b/src/cli.tsx index 261897c..413acfd 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -173,7 +173,10 @@ program 'Quantization type (f16, q8_0, q4_k_m, q4_k_s)', ) .option('-o, --output ', 'Output filename') - .option('--skip-fuse', 'Skip adapter fusion (if already fused)') + .option( + '--skip-fuse', + 'Skip adapter fusion — requires a fused/ cache from a previous export', + ) .action(async options => { const {ExportCommand} = await import('./commands/export.js'); render(); @@ -293,4 +296,26 @@ program render(); }); +// Clean command +program + .command('clean') + .description('Remove the cached fused model to reclaim disk space') + .option('-y, --yes', 'Skip the confirmation prompt (for scripts and CI)') + .action(async (options: {yes?: boolean}) => { + // Only require --yes when there's actually a confirmation to answer — + // "nothing to clean" and "not a project" are safe to just report. + if (!options.yes && !supportsRawMode()) { + const {configExists, getFusedModelDir} = await import('./lib/config.js'); + const {existsSync} = await import('node:fs'); + if (configExists() && existsSync(getFusedModelDir())) { + console.error(interactiveRequiredMessage('clean')); + console.error('Pass --yes to clean without confirmation.'); + process.exitCode = 1; + return; + } + } + const {CleanCommand} = await import('./commands/clean.js'); + render(); + }); + program.parse(); diff --git a/src/commands/clean.tsx b/src/commands/clean.tsx new file mode 100644 index 0000000..b02a80c --- /dev/null +++ b/src/commands/clean.tsx @@ -0,0 +1,155 @@ +import {existsSync, rmSync} from 'node:fs'; +import {StatusMessage} from '@inkjs/ui'; +import {Box, Text, useApp} from 'ink'; +import {useCallback, useEffect, useState} from 'react'; +import { + ExitHint, + Header, + useAutoExit, + useKeyInput, +} from '../components/index.js'; +import { + configExists, + formatFileSize, + getDirectorySize, + getFusedModelDir, +} from '../lib/config.js'; + +interface Props { + options: { + /** Skip the y/n confirmation — needed to run under CI or in a pipeline. */ + yes?: boolean; + }; +} + +type Status = 'confirm' | 'cleaning' | 'nothing' | 'done' | 'error'; + +export function CleanCommand({options}: Props) { + const {exit} = useApp(); + const fusedDir = getFusedModelDir(); + const hasProject = configExists(); + const fusedExists = hasProject && existsSync(fusedDir); + + const [status, setStatus] = useState(() => { + if (!hasProject) return 'error'; + if (!fusedExists) return 'nothing'; + return options.yes ? 'cleaning' : 'confirm'; + }); + const [errorMessage, setErrorMessage] = useState( + 'Not a Nanotune project. Run `nanotune init` first.', + ); + const [sizeLabel] = useState( + fusedExists ? formatFileSize(getDirectorySize(fusedDir)) : null, + ); + + const doClean = useCallback(() => { + try { + rmSync(fusedDir, {recursive: true, force: true}); + setStatus('done'); + } catch (err) { + setErrorMessage( + err instanceof Error + ? err.message + : 'Failed to remove fused model cache', + ); + setStatus('error'); + } + }, [fusedDir]); + + // `--yes` (or the initial `cleaning` state it sets above) skips straight + // past the confirmation prompt. + useEffect(() => { + if (status === 'cleaning') { + doClean(); + } + }, [status, doClean]); + + useKeyInput(input => { + if (status === 'confirm') { + if (input.toLowerCase() === 'y') { + setStatus('cleaning'); + } else if (input.toLowerCase() === 'n' || input === '\x1b') { + exit(); + } + } else if ( + status === 'done' || + status === 'nothing' || + status === 'error' + ) { + exit(); + } + }); + + useAutoExit( + status === 'done' || status === 'nothing' || status === 'error', + status === 'error', + ); + + if (status === 'error' && !hasProject) { + return ( + +
+ {errorMessage} + + ); + } + + return ( + +
+ + {status === 'confirm' && ( + + + Fused model cache: {sizeLabel} at{' '} + .nanotune/models/fused + + + This is kept to speed up repeat exports via --skip-fuse. + + + + Remove it? (y/n) + + + )} + + {status === 'cleaning' && Removing fused model cache...} + + {status === 'nothing' && ( + + + Nothing to clean — no fused model cache found. + + + Press any key to exit + + )} + + {status === 'done' && ( + + + Removed fused model cache + + + + Freed: {sizeLabel} + + + The next `nanotune export` will re-fuse the adapter. + + + Press any key to exit + + )} + + {status === 'error' && hasProject && ( + + {errorMessage} + + Press any key to exit + + )} + + ); +} diff --git a/src/commands/commands.spec.tsx b/src/commands/commands.spec.tsx index ef9a080..6a8d046 100644 --- a/src/commands/commands.spec.tsx +++ b/src/commands/commands.spec.tsx @@ -1,10 +1,12 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import test from "ava"; import { Text } from "ink"; import { render } from "ink-testing-library"; import { useKeyInput } from "../components/index.js"; +import { getFusedModelDir } from "../lib/config.js"; import { loadTrainingData } from "../lib/data.js"; +import { CleanCommand } from "./clean.js"; import { streamPreview } from "./chat.js"; import { DataExportCommand } from "./data/export.js"; import { DataImportCommand } from "./data/import.js"; @@ -61,6 +63,12 @@ function writeExamples(lines: object[]) { ); } +function writeFusedModel() { + const fusedDir = join(NANOTUNE_DIR, "models", "fused"); + mkdirSync(fusedDir, { recursive: true }); + writeFileSync(join(fusedDir, "model.safetensors"), "x".repeat(1024)); +} + function writeEvalExamples(lines: object[]) { writeFileSync( join(DATA_DIR, "valid.jsonl"), @@ -280,6 +288,93 @@ test.serial("DataImportCommand without yes waits for confirmation", async (t) => } }); +// ── clean ──────────────────────────────────────────────────────────── + +test.serial("CleanCommand renders its error state with no project", async (t) => { + try { + setupEmptyDir(); + const output = await renderCommand( + , + "Not a Nanotune project", + ); + t.true(output.includes("Not a Nanotune project")); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand reports nothing to clean when fused/ is absent", async (t) => { + try { + setupProject(); + const output = await renderCommand( + , + "Nothing to clean", + ); + t.true(output.includes("Nothing to clean")); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand with yes removes fused/ without prompting", async (t) => { + try { + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + t.true(existsSync(fusedDir)); + const output = await renderCommand( + , + "Removed fused model cache", + ); + t.false(output.includes("Remove it?")); + t.true(output.includes("Removed fused model cache")); + t.true(output.includes("Freed:")); + t.false(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand without yes waits for confirmation before deleting", async (t) => { + try { + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const output = await renderCommand( + , + "Remove it?", + ); + t.true(output.includes("Remove it?")); + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +// ── status: fused model cache ─────────────────────────────────────── + +test.serial("StatusCommand shows the fused model cache when present", async (t) => { + try { + setupProject(); + writeFusedModel(); + const output = await renderCommand(, "Fused model cache"); + t.true(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + +test.serial("StatusCommand omits the fused model cache line when absent", async (t) => { + try { + setupProject(); + const output = await renderCommand(, "Exports:"); + t.false(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + // ── chat streaming preview ──────────────────────────────────────────── test("streamPreview passes short content through untouched", (t) => { diff --git a/src/commands/export.spec.ts b/src/commands/export.spec.ts new file mode 100644 index 0000000..f92371e --- /dev/null +++ b/src/commands/export.spec.ts @@ -0,0 +1,18 @@ +import test from "ava"; +import { skipFuseValidationError } from "./export.js"; + +test("skipFuseValidationError is null when --skip-fuse is not set", (t) => { + t.is(skipFuseValidationError(false, false), null); + t.is(skipFuseValidationError(undefined, false), null); +}); + +test("skipFuseValidationError is null when --skip-fuse is set and fused/ exists", (t) => { + t.is(skipFuseValidationError(true, true), null); +}); + +test("skipFuseValidationError reports a clear error when --skip-fuse is set but fused/ is missing", (t) => { + const message = skipFuseValidationError(true, false); + t.truthy(message); + t.true(message!.includes("--skip-fuse")); + t.true(message!.includes("fused")); +}); diff --git a/src/commands/export.tsx b/src/commands/export.tsx index b924680..7321a29 100644 --- a/src/commands/export.tsx +++ b/src/commands/export.tsx @@ -13,7 +13,10 @@ import { } from '../components/index.js'; import { configExists, + formatFileSize, getAdaptersDir, + getDirectorySize, + getFusedModelDir, getModelsDir, loadConfig, } from '../lib/config.js'; @@ -47,6 +50,21 @@ interface Step { status: 'pending' | 'running' | 'done' | 'error'; } +/** + * `--skip-fuse` relies on a fused model from a previous export. Pulled out + * as a pure function so it's testable without rendering the full command + * (which asserts Apple Silicon up front). + */ +export function skipFuseValidationError( + skipFuse: boolean | undefined, + fusedModelExists: boolean, +): string | null { + if (skipFuse && !fusedModelExists) { + return 'No fused model found at .nanotune/models/fused. Run `nanotune export` without --skip-fuse first.'; + } + return null; +} + export function ExportCommand({options}: Props) { const {exit} = useApp(); const [status, setStatus] = useState('checking'); @@ -55,6 +73,7 @@ export function ExportCommand({options}: Props) { const [currentStep, setCurrentStep] = useState(''); const [outputPath, setOutputPath] = useState(null); const [fileSize, setFileSize] = useState(null); + const [fusedCacheInfo, setFusedCacheInfo] = useState(null); const [steps, setSteps] = useState([ {name: 'Fusing adapters', status: 'pending'}, {name: 'Converting to GGUF', status: 'pending'}, @@ -91,6 +110,7 @@ export function ExportCommand({options}: Props) { const config = loadConfig(); const adaptersDir = getAdaptersDir(); const modelsDir = getModelsDir(); + const fusedModelPath = getFusedModelDir(); // Check adapters exist const adapterFile = join(adaptersDir, 'adapters.safetensors'); @@ -100,6 +120,19 @@ export function ExportCommand({options}: Props) { return; } + // Fail fast with a clear message rather than a cryptic error deep + // inside GGUF conversion if --skip-fuse is passed with no fused + // model on disk (e.g. after `nanotune clean`). + const skipFuseError = skipFuseValidationError( + options.skipFuse, + existsSync(fusedModelPath), + ); + if (skipFuseError) { + setError(skipFuseError); + setStatus('error'); + return; + } + // Check llama.cpp setStatus('checking'); const hasLlamaCpp = await checkLlamaCppInstalled(); @@ -116,7 +149,6 @@ export function ExportCommand({options}: Props) { const outputName = options.output || `${config.export.outputName}-${quantization}`; const finalOutputPath = join(modelsDir, `${outputName}.gguf`); - const fusedModelPath = join(modelsDir, 'fused'); // Step 1: Fuse adapters if (!options.skipFuse) { @@ -162,8 +194,13 @@ export function ExportCommand({options}: Props) { // Get file size if (existsSync(finalOutputPath)) { const stats = statSync(finalOutputPath); - const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); - setFileSize(`${sizeMB} MB`); + setFileSize(formatFileSize(stats.size)); + } + + // The fused model cache is kept for faster re-exports via + // --skip-fuse; surface its size so it isn't a silent disk cost. + if (existsSync(fusedModelPath)) { + setFusedCacheInfo(formatFileSize(getDirectorySize(fusedModelPath))); } setOutputPath(finalOutputPath); @@ -260,6 +297,12 @@ export function ExportCommand({options}: Props) { Size: {fileSize} )} + {fusedCacheInfo && ( + + Fused model cache: {fusedCacheInfo} at .nanotune/models/fused + (kept for faster re-exports — run `nanotune clean` to remove) + + )} Test your model: llama-cli -m {outputPath} -p "Your prompt here" diff --git a/src/commands/status.tsx b/src/commands/status.tsx index cfda066..31ba391 100644 --- a/src/commands/status.tsx +++ b/src/commands/status.tsx @@ -12,8 +12,11 @@ import { import { configExists, findLatestBenchmark, + formatFileSize, getAdaptersDir, getDataDir, + getDirectorySize, + getFusedModelDir, getModelsDir, loadBenchmark, loadConfig, @@ -36,14 +39,6 @@ function formatRelativeTime(date: Date): string { return 'just now'; } -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - export function StatusCommand() { const {exit} = useApp(); const hasConfig = configExists(); @@ -104,6 +99,13 @@ export function StatusCommand() { .sort((a, b) => b.modified.getTime() - a.modified.getTime()) : []; + // Fused model cache + const fusedDir = getFusedModelDir(); + const fusedExists = existsSync(fusedDir); + const fusedSize = fusedExists + ? formatFileSize(getDirectorySize(fusedDir)) + : null; + // Latest benchmark let latestBenchmark: BenchmarkResult | null = null; const latestBenchmarkPath = findLatestBenchmark(); @@ -179,6 +181,12 @@ export function StatusCommand() { ) : ( {' '}No exported models yet )} + {fusedExists && ( + + {' '}Fused model cache: {fusedSize} (run{' '} + nanotune clean to remove) + + )} Benchmarks: diff --git a/src/lib/config.ts b/src/lib/config.ts index 4a6e781..e60fb5a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -62,6 +62,38 @@ export function getChatsDir(): string { return join(getProjectDir(), 'chats'); } +export function getFusedModelDir(): string { + return join(getModelsDir(), 'fused'); +} + +/** + * Recursively sum the size of every file under `dirPath`. Returns 0 if the + * directory doesn't exist. Used to report the size of the fused/ model + * cache, which is a directory of HF-format model files rather than a + * single file. + */ +export function getDirectorySize(dirPath: string): number { + if (!existsSync(dirPath)) { + return 0; + } + let total = 0; + for (const entry of readdirSync(dirPath, {withFileTypes: true})) { + const entryPath = join(dirPath, entry.name); + total += entry.isDirectory() + ? getDirectorySize(entryPath) + : statSync(entryPath).size; + } + return total; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + export function configExists(): boolean { return existsSync(getConfigPath()); } From d82f623bbe42a95c86995b821b5b2d09caa37b05 Mon Sep 17 00:00:00 2001 From: rohanshrma222 Date: Mon, 31 Aug 2026 18:35:23 +0530 Subject: [PATCH 2/4] fix: address review feedback on fused/ cache cleanup --- src/cli.tsx | 6 +- src/commands/clean.tsx | 23 +++---- src/commands/commands.spec.tsx | 107 +++++++++++++++++++++++++++++++++ src/commands/export.tsx | 5 +- src/commands/status.tsx | 13 ++-- src/lib/config.spec.ts | 98 ++++++++++++++++++++++++++++++ src/lib/config.ts | 26 +++++++- 7 files changed, 254 insertions(+), 24 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 413acfd..4e44803 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -305,9 +305,9 @@ program // Only require --yes when there's actually a confirmation to answer — // "nothing to clean" and "not a project" are safe to just report. if (!options.yes && !supportsRawMode()) { - const {configExists, getFusedModelDir} = await import('./lib/config.js'); - const {existsSync} = await import('node:fs'); - if (configExists() && existsSync(getFusedModelDir())) { + const {configExists, getFusedModelDir, hasUsableFusedModel} = + await import('./lib/config.js'); + if (configExists() && hasUsableFusedModel(getFusedModelDir())) { console.error(interactiveRequiredMessage('clean')); console.error('Pass --yes to clean without confirmation.'); process.exitCode = 1; diff --git a/src/commands/clean.tsx b/src/commands/clean.tsx index b02a80c..fcc6263 100644 --- a/src/commands/clean.tsx +++ b/src/commands/clean.tsx @@ -1,4 +1,4 @@ -import {existsSync, rmSync} from 'node:fs'; +import {rmSync} from 'node:fs'; import {StatusMessage} from '@inkjs/ui'; import {Box, Text, useApp} from 'ink'; import {useCallback, useEffect, useState} from 'react'; @@ -13,6 +13,7 @@ import { formatFileSize, getDirectorySize, getFusedModelDir, + hasUsableFusedModel, } from '../lib/config.js'; interface Props { @@ -26,18 +27,16 @@ type Status = 'confirm' | 'cleaning' | 'nothing' | 'done' | 'error'; export function CleanCommand({options}: Props) { const {exit} = useApp(); - const fusedDir = getFusedModelDir(); const hasProject = configExists(); - const fusedExists = hasProject && existsSync(fusedDir); + const fusedDir = getFusedModelDir(); + const fusedExists = hasProject && hasUsableFusedModel(fusedDir); const [status, setStatus] = useState(() => { if (!hasProject) return 'error'; if (!fusedExists) return 'nothing'; return options.yes ? 'cleaning' : 'confirm'; }); - const [errorMessage, setErrorMessage] = useState( - 'Not a Nanotune project. Run `nanotune init` first.', - ); + const [error, setError] = useState(null); const [sizeLabel] = useState( fusedExists ? formatFileSize(getDirectorySize(fusedDir)) : null, ); @@ -47,7 +46,7 @@ export function CleanCommand({options}: Props) { rmSync(fusedDir, {recursive: true, force: true}); setStatus('done'); } catch (err) { - setErrorMessage( + setError( err instanceof Error ? err.message : 'Failed to remove fused model cache', @@ -85,11 +84,13 @@ export function CleanCommand({options}: Props) { status === 'error', ); - if (status === 'error' && !hasProject) { + if (!hasProject) { return (
- {errorMessage} + + Not a Nanotune project. Run `nanotune init` first. + ); } @@ -143,9 +144,9 @@ export function CleanCommand({options}: Props) { )} - {status === 'error' && hasProject && ( + {status === 'error' && ( - {errorMessage} + {error} Press any key to exit diff --git a/src/commands/commands.spec.tsx b/src/commands/commands.spec.tsx index 6a8d046..4f89aa9 100644 --- a/src/commands/commands.spec.tsx +++ b/src/commands/commands.spec.tsx @@ -69,6 +69,14 @@ function writeFusedModel() { writeFileSync(join(fusedDir, "model.safetensors"), "x".repeat(1024)); } +// Simulates an export interrupted before mlx_lm.fuse finished writing +// weights: the directory exists but has no .safetensors file yet. +function writeIncompleteFusedModel() { + const fusedDir = join(NANOTUNE_DIR, "models", "fused"); + mkdirSync(fusedDir, { recursive: true }); + writeFileSync(join(fusedDir, "config.json"), "{}"); +} + function writeEvalExamples(lines: object[]) { writeFileSync( join(DATA_DIR, "valid.jsonl"), @@ -352,6 +360,105 @@ test.serial("CleanCommand without yes waits for confirmation before deleting", a } }); +test.serial("CleanCommand treats a leftover incomplete fused/ as nothing to clean", async (t) => { + try { + setupProject(); + writeIncompleteFusedModel(); + const fusedDir = getFusedModelDir(); + const output = await renderCommand( + , + "Nothing to clean", + ); + t.true(output.includes("Nothing to clean")); + t.false(output.includes("Remove it?")); + // The (non-safetensors) leftover directory is left alone, not deleted. + t.true(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand deletes the cache on a confirming 'y' keypress", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("y"); + const timeout = 2000; + const pollInterval = 10; + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (instance.frames.join("\n").includes("Removed fused model cache")) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + const output = instance.frames.join("\n"); + instance.unmount(); + + t.true(output.includes("Removed fused model cache")); + t.true(output.includes("Freed:")); + t.false(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + +test.serial("CleanCommand leaves the cache in place on an 'n' keypress", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("n"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const output = instance.frames.join("\n"); + instance.unmount(); + + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + +test.serial("CleanCommand leaves the cache in place on Escape", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("\x1b"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const output = instance.frames.join("\n"); + instance.unmount(); + + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + // ── status: fused model cache ─────────────────────────────────────── test.serial("StatusCommand shows the fused model cache when present", async (t) => { diff --git a/src/commands/export.tsx b/src/commands/export.tsx index 7321a29..714efde 100644 --- a/src/commands/export.tsx +++ b/src/commands/export.tsx @@ -18,6 +18,7 @@ import { getDirectorySize, getFusedModelDir, getModelsDir, + hasUsableFusedModel, loadConfig, } from '../lib/config.js'; import { @@ -125,7 +126,7 @@ export function ExportCommand({options}: Props) { // model on disk (e.g. after `nanotune clean`). const skipFuseError = skipFuseValidationError( options.skipFuse, - existsSync(fusedModelPath), + hasUsableFusedModel(fusedModelPath), ); if (skipFuseError) { setError(skipFuseError); @@ -199,7 +200,7 @@ export function ExportCommand({options}: Props) { // The fused model cache is kept for faster re-exports via // --skip-fuse; surface its size so it isn't a silent disk cost. - if (existsSync(fusedModelPath)) { + if (hasUsableFusedModel(fusedModelPath)) { setFusedCacheInfo(formatFileSize(getDirectorySize(fusedModelPath))); } diff --git a/src/commands/status.tsx b/src/commands/status.tsx index 31ba391..2201d1f 100644 --- a/src/commands/status.tsx +++ b/src/commands/status.tsx @@ -2,6 +2,7 @@ import {existsSync, readdirSync, statSync} from 'node:fs'; import {join} from 'node:path'; import {StatusMessage} from '@inkjs/ui'; import {Box, Text, useApp} from 'ink'; +import {useMemo} from 'react'; import { ExitHint, Header, @@ -42,6 +43,12 @@ function formatRelativeTime(date: Date): string { export function StatusCommand() { const {exit} = useApp(); const hasConfig = configExists(); + const fusedDir = getFusedModelDir(); + const fusedExists = hasConfig && existsSync(fusedDir); + const fusedDirSize = useMemo( + () => (fusedExists ? getDirectorySize(fusedDir) : 0), + [fusedExists, fusedDir], + ); useKeyInput((input, key) => { if (key.escape || key.return || input === 'q') { @@ -100,11 +107,7 @@ export function StatusCommand() { : []; // Fused model cache - const fusedDir = getFusedModelDir(); - const fusedExists = existsSync(fusedDir); - const fusedSize = fusedExists - ? formatFileSize(getDirectorySize(fusedDir)) - : null; + const fusedSize = fusedExists ? formatFileSize(fusedDirSize) : null; // Latest benchmark let latestBenchmark: BenchmarkResult | null = null; diff --git a/src/lib/config.spec.ts b/src/lib/config.spec.ts index a0d755b..acfed3d 100644 --- a/src/lib/config.spec.ts +++ b/src/lib/config.spec.ts @@ -14,6 +14,9 @@ import { createDefaultConfig, ensureBenchmarksDir, findLatestGGUF, + formatFileSize, + getDirectorySize, + hasUsableFusedModel, formatConfigIssues, listBenchmarks, resolveBenchmarkPath, @@ -681,6 +684,101 @@ test.serial("loadConfig prints each unknown key once across loads", (t) => { } }); +// ── formatFileSize ────────────────────────────────────────────────── + +test("formatFileSize formats bytes below 1 KB", (t) => { + t.is(formatFileSize(0), "0 B"); + t.is(formatFileSize(1023), "1023 B"); +}); + +test("formatFileSize formats kilobytes", (t) => { + t.is(formatFileSize(1024), "1.0 KB"); + t.is(formatFileSize(1024 * 1024 - 1), "1024.0 KB"); +}); + +test("formatFileSize formats megabytes", (t) => { + t.is(formatFileSize(1024 * 1024), "1.0 MB"); + t.is(formatFileSize(1024 * 1024 * 1024 - 1), "1024.0 MB"); +}); + +test("formatFileSize formats gigabytes with two decimal places", (t) => { + t.is(formatFileSize(1024 * 1024 * 1024), "1.00 GB"); + t.is(formatFileSize(1.5 * 1024 * 1024 * 1024), "1.50 GB"); +}); + +// ── getDirectorySize / hasUsableFusedModel ────────────────────────── + +const SIZE_TEST_DIR = join(ORIG_CWD, ".test-config-dirsize"); + +function resetSizeTestDir() { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + mkdirSync(SIZE_TEST_DIR, { recursive: true }); +} + +test.serial("getDirectorySize returns 0 for a missing directory", (t) => { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + t.is(getDirectorySize(SIZE_TEST_DIR), 0); +}); + +test.serial("getDirectorySize sums files in a flat directory", (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "a.bin"), "x".repeat(10)); + writeFileSync(join(SIZE_TEST_DIR, "b.bin"), "x".repeat(20)); + t.is(getDirectorySize(SIZE_TEST_DIR), 30); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } +}); + +test.serial("getDirectorySize recurses into nested subdirectories", (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "a.bin"), "x".repeat(10)); + const nested = join(SIZE_TEST_DIR, "nested"); + mkdirSync(nested); + writeFileSync(join(nested, "b.bin"), "x".repeat(20)); + const deeper = join(nested, "deeper"); + mkdirSync(deeper); + writeFileSync(join(deeper, "c.bin"), "x".repeat(5)); + t.is(getDirectorySize(SIZE_TEST_DIR), 35); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } +}); + +test.serial("hasUsableFusedModel is false for a missing directory", (t) => { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + t.false(hasUsableFusedModel(SIZE_TEST_DIR)); +}); + +test.serial( + "hasUsableFusedModel is false when the directory has no .safetensors file", + (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "config.json"), "{}"); + t.false(hasUsableFusedModel(SIZE_TEST_DIR)); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } + }, +); + +test.serial( + "hasUsableFusedModel is true once a .safetensors file is present", + (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "config.json"), "{}"); + writeFileSync(join(SIZE_TEST_DIR, "model.safetensors"), "x"); + t.true(hasUsableFusedModel(SIZE_TEST_DIR)); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } + }, +); + // ── ensureBenchmarksDir ─────────────────────────────────────────────── // diff --git a/src/lib/config.ts b/src/lib/config.ts index e60fb5a..4a9e5c3 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -79,13 +79,33 @@ export function getDirectorySize(dirPath: string): number { let total = 0; for (const entry of readdirSync(dirPath, {withFileTypes: true})) { const entryPath = join(dirPath, entry.name); - total += entry.isDirectory() - ? getDirectorySize(entryPath) - : statSync(entryPath).size; + try { + if (entry.isDirectory()) { + total += getDirectorySize(entryPath); + } else if (entry.isFile()) { + total += statSync(entryPath).size; + } + } catch { + // Broken symlink or unreadable entry (permissions, race with a + // concurrent delete) — skip it rather than failing the whole walk. + } } return total; } +/** + * Whether `dirPath` contains a real fused-model artifact rather than just an + * empty or partially-written directory. `mlx_lm.fuse` creates `--save-path` + * before it finishes writing weights, so directory existence alone isn't a + * reliable signal that a fuse completed. + */ +export function hasUsableFusedModel(dirPath: string): boolean { + if (!existsSync(dirPath)) { + return false; + } + return readdirSync(dirPath).some(name => name.endsWith('.safetensors')); +} + export function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; From 4b41d0a1de6bc17afdeeec4f9238655aad58c5b0 Mon Sep 17 00:00:00 2001 From: rohanshrma222 Date: Mon, 31 Aug 2026 22:25:09 +0530 Subject: [PATCH 3/4] fix: relocate skipFuseValidationError to avoid tanking coverage --- src/commands/export.spec.ts | 18 ------------------ src/commands/export.tsx | 16 +--------------- src/lib/config.spec.ts | 19 +++++++++++++++++++ src/lib/config.ts | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 33 deletions(-) delete mode 100644 src/commands/export.spec.ts diff --git a/src/commands/export.spec.ts b/src/commands/export.spec.ts deleted file mode 100644 index f92371e..0000000 --- a/src/commands/export.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import test from "ava"; -import { skipFuseValidationError } from "./export.js"; - -test("skipFuseValidationError is null when --skip-fuse is not set", (t) => { - t.is(skipFuseValidationError(false, false), null); - t.is(skipFuseValidationError(undefined, false), null); -}); - -test("skipFuseValidationError is null when --skip-fuse is set and fused/ exists", (t) => { - t.is(skipFuseValidationError(true, true), null); -}); - -test("skipFuseValidationError reports a clear error when --skip-fuse is set but fused/ is missing", (t) => { - const message = skipFuseValidationError(true, false); - t.truthy(message); - t.true(message!.includes("--skip-fuse")); - t.true(message!.includes("fused")); -}); diff --git a/src/commands/export.tsx b/src/commands/export.tsx index 714efde..10e9fff 100644 --- a/src/commands/export.tsx +++ b/src/commands/export.tsx @@ -20,6 +20,7 @@ import { getModelsDir, hasUsableFusedModel, loadConfig, + skipFuseValidationError, } from '../lib/config.js'; import { checkLlamaCppInstalled, @@ -51,21 +52,6 @@ interface Step { status: 'pending' | 'running' | 'done' | 'error'; } -/** - * `--skip-fuse` relies on a fused model from a previous export. Pulled out - * as a pure function so it's testable without rendering the full command - * (which asserts Apple Silicon up front). - */ -export function skipFuseValidationError( - skipFuse: boolean | undefined, - fusedModelExists: boolean, -): string | null { - if (skipFuse && !fusedModelExists) { - return 'No fused model found at .nanotune/models/fused. Run `nanotune export` without --skip-fuse first.'; - } - return null; -} - export function ExportCommand({options}: Props) { const {exit} = useApp(); const [status, setStatus] = useState('checking'); diff --git a/src/lib/config.spec.ts b/src/lib/config.spec.ts index acfed3d..9cd115b 100644 --- a/src/lib/config.spec.ts +++ b/src/lib/config.spec.ts @@ -17,6 +17,7 @@ import { formatFileSize, getDirectorySize, hasUsableFusedModel, + skipFuseValidationError, formatConfigIssues, listBenchmarks, resolveBenchmarkPath, @@ -779,6 +780,24 @@ test.serial( }, ); +// ── skipFuseValidationError ───────────────────────────────────────── + +test("skipFuseValidationError is null when --skip-fuse is not set", (t) => { + t.is(skipFuseValidationError(false, false), null); + t.is(skipFuseValidationError(undefined, false), null); +}); + +test("skipFuseValidationError is null when --skip-fuse is set and fused/ exists", (t) => { + t.is(skipFuseValidationError(true, true), null); +}); + +test("skipFuseValidationError reports a clear error when --skip-fuse is set but fused/ is missing", (t) => { + const message = skipFuseValidationError(true, false); + t.truthy(message); + t.true(message!.includes("--skip-fuse")); + t.true(message!.includes("fused")); +}); + // ── ensureBenchmarksDir ─────────────────────────────────────────────── // diff --git a/src/lib/config.ts b/src/lib/config.ts index 4a9e5c3..2db7a9c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -106,6 +106,22 @@ export function hasUsableFusedModel(dirPath: string): boolean { return readdirSync(dirPath).some(name => name.endsWith('.safetensors')); } +/** + * `--skip-fuse` relies on a fused model from a previous export. Lives here + * rather than in `commands/export.tsx` so it's testable without importing + * that file's `ExportCommand` component, which asserts Apple Silicon up + * front and so can never actually render in CI. + */ +export function skipFuseValidationError( + skipFuse: boolean | undefined, + fusedModelExists: boolean, +): string | null { + if (skipFuse && !fusedModelExists) { + return 'No fused model found at .nanotune/models/fused. Run `nanotune export` without --skip-fuse first.'; + } + return null; +} + export function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; From b1f5b800bf54b093700f350901d48c48ca114b31 Mon Sep 17 00:00:00 2001 From: rohanshrma222 Date: Tue, 1 Sep 2026 00:02:30 +0530 Subject: [PATCH 4/4] fix: align status with clean/export's fused-model check, avoid redundant fs walk --- src/commands/clean.tsx | 2 +- src/commands/commands.spec.tsx | 14 ++++++++++++++ src/commands/status.tsx | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/commands/clean.tsx b/src/commands/clean.tsx index fcc6263..6f006e1 100644 --- a/src/commands/clean.tsx +++ b/src/commands/clean.tsx @@ -37,7 +37,7 @@ export function CleanCommand({options}: Props) { return options.yes ? 'cleaning' : 'confirm'; }); const [error, setError] = useState(null); - const [sizeLabel] = useState( + const [sizeLabel] = useState(() => fusedExists ? formatFileSize(getDirectorySize(fusedDir)) : null, ); diff --git a/src/commands/commands.spec.tsx b/src/commands/commands.spec.tsx index 4f89aa9..06dcd89 100644 --- a/src/commands/commands.spec.tsx +++ b/src/commands/commands.spec.tsx @@ -482,6 +482,20 @@ test.serial("StatusCommand omits the fused model cache line when absent", async } }); +test.serial("StatusCommand omits the fused model cache line for a leftover incomplete fused/", async (t) => { + // Regression: status used existsSync while clean used hasUsableFusedModel, + // so an interrupted export made status report a cache that clean then + // said didn't exist. Both must agree. + try { + setupProject(); + writeIncompleteFusedModel(); + const output = await renderCommand(, "Exports:"); + t.false(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + // ── chat streaming preview ──────────────────────────────────────────── test("streamPreview passes short content through untouched", (t) => { diff --git a/src/commands/status.tsx b/src/commands/status.tsx index 2201d1f..89da672 100644 --- a/src/commands/status.tsx +++ b/src/commands/status.tsx @@ -19,6 +19,7 @@ import { getDirectorySize, getFusedModelDir, getModelsDir, + hasUsableFusedModel, loadBenchmark, loadConfig, } from '../lib/config.js'; @@ -44,7 +45,7 @@ export function StatusCommand() { const {exit} = useApp(); const hasConfig = configExists(); const fusedDir = getFusedModelDir(); - const fusedExists = hasConfig && existsSync(fusedDir); + const fusedExists = hasConfig && hasUsableFusedModel(fusedDir); const fusedDirSize = useMemo( () => (fusedExists ? getDirectorySize(fusedDir) : 0), [fusedExists, fusedDir],