diff --git a/CHANGELOG.md b/CHANGELOG.md index ede4e262..9de4c606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ## [Unreleased] +### `mcpp clean --stale`: 只清 target/ 里已无构建使用的指纹目录 (#565) + +每次配置指纹变化都会在 `target/<三元组>/` 下新开一个目录, 旧目录从不回收; `mcpp clean` 只有整删一档, +代价是全量重编, 于是没人跑. `mcpp clean --stale` 以 `target/.build_cache` 里记录的 (三元组, 指纹) 为 +当前, 删掉记录过的三元组目录下其余兄弟目录并报告各自体积; 未被记录但在 `--older-than` (默认 1d) 之内 +写过的目录保留 (`mcpp test` 的构建不写记录). `--dry-run` 只列出. 没有构建记录时拒绝执行而不是猜; 记录之外的目录 +(如 `mcpp pack` 的 `dist/`) 不碰. `fingerprint changed` 的警告末尾现在附带这条 +命令, 让增长可见. + ## [2026.9.5.3] — 2026-09-05 ### 官方构建插件集中为一个包:`mcpp:plugins` diff --git a/docs/00-getting-started.md b/docs/00-getting-started.md index b1f5ea48..665c171a 100644 --- a/docs/00-getting-started.md +++ b/docs/00-getting-started.md @@ -131,6 +131,8 @@ human-readable. ```bash mcpp build # incremental build mcpp clean # clean target/ +mcpp clean --stale # drop only target/// dirs no build still uses + # (--dry-run lists and deletes nothing; --older-than 3d keeps newer unrecorded ones) mcpp test # compile and run tests/**/*.cpp — one binary per file, # framework-agnostic (bare main, or gtest via [dev-dependencies]) mcpp test # only tests whose name contains diff --git a/docs/zh/00-getting-started.md b/docs/zh/00-getting-started.md index 745f33f1..25d54178 100644 --- a/docs/zh/00-getting-started.md +++ b/docs/zh/00-getting-started.md @@ -122,6 +122,7 @@ workspace 中运行。插件稳定依赖进程退出码和生成的 `compile_com ```bash mcpp build # 增量构建 mcpp clean # 清理 target/ +mcpp clean --stale # 只删 target/<三元组>/<指纹>/ 下已无构建使用的目录 (--dry-run 只列出; --older-than 3d 保留更新的未记录目录) mcpp test # 编译并运行 tests/**/*.cpp —— 每文件一个独立二进制, # 框架无关(裸 main,或经 [dev-dependencies] 使用 gtest) mcpp test # 只运行名字包含 的测试 diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 89853d12..92381fa8 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -25,6 +25,7 @@ import mcpp.build.backend; import mcpp.build.ninja; import mcpp.build.runtime_validation; import mcpp.bmi_cache; +import mcpp.bmi_cache.maintenance; // dir_size + human_bytes, for `clean --stale` import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.scanner; @@ -813,7 +814,8 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, auto newFp = ctx.outputDir.filename().string(); if (e.fingerprint != newFp) { mcpp::ui::warning(std::format( - "fingerprint changed ({} → {}), full rebuild", + "fingerprint changed ({} → {}), full rebuild; " + "`mcpp clean --stale` drops the directories no build still uses", e.fingerprint, newFp)); } break; @@ -2568,4 +2570,126 @@ export int clean_project(bool wipe_bmi) { return 0; } +// `mcpp clean --stale` driver (#565). +// +// Every build lands in target///, and a changed +// fingerprint opens a fresh directory while the old one is never touched +// again. "Current" is what target/.build_cache records: one entry per +// (target, profile) built recently, which is the set the fast paths and +// `mcpp run` still resolve to. Every other directory under a recorded +// target// is a leftover from a configuration that no longer exists +// and can go without forcing a rebuild of anything that does. +// +// Three deliberate limits, each erring toward deleting a directory that a +// rebuild can recreate rather than one that cannot: +// * Entries are matched by (triple directory, fingerprint), not by the +// absolute path the record stores, so a moved checkout is not read as +// "everything is stale". +// * Only triple directories named by the record are visited. Anything else +// under target/ (`dist/` from `mcpp pack`, whatever a later release adds) +// is not a fingerprint directory and is left alone — as is a triple whose +// entry has been evicted from the record; that one stays until it is +// built again. +// * The record keys on (target, profile) while the fingerprint also folds in +// features; a `--no-cache` build writes no entry at all; and `mcpp test` +// builds through run_tests, which never writes one. So an unrecorded +// directory is not proof of staleness, and recomputing fingerprints here +// is not an option (prepare_build resolves dependencies and may reach the +// network; a clean command must not). The guard that costs nothing and +// matches how `cache prune` already thinks: an unrecorded directory +// written within `--older-than` (default one day) is kept — that is the +// build somebody just ran. Older and unrecorded goes; the cost of being +// wrong there is one rebuild of a configuration nobody has touched since. +// +// With no record at all there is nothing to compare against, and the command +// refuses rather than guess. +export int clean_stale(bool dryRun, std::int64_t keepWithinSecs) { + namespace fs = std::filesystem; + auto root = mcpp::project::find_manifest_root(fs::current_path()); + if (!root) { std::println(stderr, "error: not in an mcpp package"); return 2; } + const fs::path target = *root / "target"; + + std::set> current; // (triple dir, fingerprint) + std::set currentTriples; + for (const auto& e : read_build_cache(*root)) { + const fs::path out(e.outputDir); + const std::string fp = e.fingerprint.empty() ? out.filename().string() : e.fingerprint; + const std::string triple = out.parent_path().filename().string(); + if (fp.empty() || triple.empty()) continue; + current.emplace(triple, fp); + currentTriples.insert(triple); + } + if (current.empty()) { + std::println(stderr, "error: {} has no build record, so nothing is known to be current; " + "run `mcpp build` once, then retry", + (*root / kBuildCacheFile).string()); + return 2; + } + + std::uintmax_t bytes = 0; + std::size_t removed = 0, failed = 0; + std::error_code ec; + for (fs::directory_iterator tripleIt(target, ec), end; tripleIt != end; tripleIt.increment(ec)) { + std::error_code tec; + const std::string triple = tripleIt->path().filename().string(); + if (!tripleIt->is_directory(tec) || tec || !currentTriples.contains(triple)) continue; + std::error_code iec; + for (fs::directory_iterator fpIt(tripleIt->path(), iec), fend; fpIt != fend; fpIt.increment(iec)) { + std::error_code fec; + if (!fpIt->is_directory(fec) || fec) continue; + const fs::path dir = fpIt->path(); + const std::string fp = dir.filename().string(); + if (current.contains({triple, fp})) continue; + const std::string shown = std::format("target/{}/{}", triple, fp); + // build.ninja is rewritten by every build; the directory's own + // mtime only moves when an entry is added or removed. + const auto stamp = dir / "build.ninja"; + const auto written = fs::last_write_time(fs::exists(stamp, fec) ? stamp : dir, fec); + if (!fec) { + const auto age = std::chrono::duration_cast( + fs::file_time_type::clock::now() - written).count(); + if (age < keepWithinSecs) { + const std::string ago = age < 3600 ? std::format("{}m", std::max(age / 60, 1)) + : age < 86400 ? std::format("{}h", age / 3600) + : std::format("{}d", age / 86400); + std::println("kept {} (not in the record, but written {} ago; see --older-than)", shown, ago); + continue; + } + } + const auto size = mcpp::bmi_cache::dir_size(dir); + if (dryRun) { + std::println("would remove {} ({})", shown, mcpp::bmi_cache::human_bytes(size)); + } else { + std::error_code rec; + fs::remove_all(dir, rec); + if (rec) { + std::println(stderr, "error: cannot remove {}: {}", shown, rec.message()); + ++failed; + continue; + } + std::println("removed {} ({})", shown, mcpp::bmi_cache::human_bytes(size)); + } + bytes += size; + ++removed; + } + if (iec) { + std::println(stderr, "error: cannot read {}: {}", tripleIt->path().string(), iec.message()); + ++failed; + } + } + if (ec) { + std::println(stderr, "error: cannot read {}: {}", target.string(), ec.message()); + return 1; + } + + if (removed == 0 && failed == 0) { + std::println("Nothing stale under {}: every fingerprint directory is recorded as current", + target.string()); + } else { + std::println("{} {} director{} ({})", dryRun ? "Would remove" : "Removed", removed, + removed == 1 ? "y" : "ies", mcpp::bmi_cache::human_bytes(bytes)); + } + return failed ? 1 : 0; +} + } // namespace mcpp::build diff --git a/src/cli.cppm b/src/cli.cppm index 46163237..a53efcf8 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -61,7 +61,7 @@ void print_usage() { std::println(" mcpp build [options] Build the current package"); std::println(" mcpp run [target] [-- args...] Build + run a binary target"); std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json, --no-runner)"); - std::println(" mcpp clean [--bmi-cache] Remove target/ (and optionally the build cache)"); + std::println(" mcpp clean [--stale] [--bmi-cache] Remove target/ (or, with --stale, only its non-current fingerprint dirs)"); std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); @@ -506,8 +506,12 @@ int run(int argc, char** argv) { return cmd_test(p, std::span(passthrough)); }))) .subcommand(cl::App("clean") - .description("Remove target/ (and optionally the global build cache)") + .description("Remove target/, or with --stale only the fingerprint directories under it that no recorded build still uses") .option(cl::Option("bmi-cache").help("Also wipe the global build cache (see `mcpp cache clean`)")) + .option(cl::Option("stale").help("Only remove target/// directories that no recorded build considers current")) + .option(cl::Option("dry-run").help("List what would be removed and delete nothing (implies --stale)")) + .option(cl::Option("older-than").takes_value().value_name("DURATION") + .help("With --stale: keep unrecorded directories written more recently than this, e.g. 12h, 3d (default 1d; 0 keeps none)")) .action(wrap_rc(cmd_clean))) .subcommand(cl::App("why") .description("Explain how the toolchain / runtime / deps / runners were resolved") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index c0448486..58339d75 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -12,6 +12,7 @@ import std; import mcpplibs.cmdline; import mcpp.build.prepare; import mcpp.build.execute; +import mcpp.bmi_cache.maintenance; // parse_duration, for `clean --stale --older-than` import mcpp.build.directives; // the device-slot table import mcpp.build.configure; import mcpp.build.coff_exports; @@ -463,6 +464,24 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, } export int cmd_clean(const mcpplibs::cmdline::ParsedArgs& parsed) { + const bool dryRun = parsed.is_flag_set("dry-run"); + if (parsed.is_flag_set("stale") || dryRun) { + if (parsed.is_flag_set("bmi-cache")) { + std::println(stderr, "error: --stale/--dry-run cannot be combined with --bmi-cache " + "(the build cache is shared across projects; use `mcpp cache gc`)"); + return 2; + } + std::int64_t keepWithinSecs = 24 * 3600; + if (auto v = parsed.value("older-than")) { + auto secs = (*v == "0") ? std::optional{0} : mcpp::bmi_cache::parse_duration(*v); + if (!secs) { + std::println(stderr, "error: invalid --older-than '{}' (expected s, m, h, d, or 0)", *v); + return 2; + } + keepWithinSecs = *secs; + } + return mcpp::build::clean_stale(dryRun, keepWithinSecs); + } return mcpp::build::clean_project(parsed.is_flag_set("bmi-cache")); } diff --git a/tests/e2e/609_clean_stale.sh b/tests/e2e/609_clean_stale.sh new file mode 100755 index 00000000..712c68dd --- /dev/null +++ b/tests/e2e/609_clean_stale.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# requires: +# 609_clean_stale.sh — `mcpp clean --stale` (#565): under target//, +# only fingerprint directories that no recorded build considers current are +# removed. A dev and a release build are both current (two fingerprints, two +# entries in target/.build_cache); a directory nobody recorded is stale. +# --dry-run lists and deletes nothing; a plain `mcpp clean --stale` deletes +# exactly the old stale one, keeps a freshly written unrecorded one (a +# `mcpp test` build looks like that) unless --older-than 0, and the survivors +# are not rebuilt afterwards. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new stale > /dev/null +cd stale + +# Refuses before any build: there is no record of what is current. +rc=0 +out=$("$MCPP" clean --stale 2>&1) || rc=$? +[[ "$rc" -ne 0 ]] || { echo "FAIL: --stale before any build should refuse: $out"; exit 1; } +echo "$out" | grep -q 'build_cache' || { echo "FAIL: refusal should name the record: $out"; exit 1; } + +"$MCPP" build > /dev/null +"$MCPP" build --release > /dev/null + +triple=$(ls target | grep -v '^\.' | head -1) +[[ -n "$triple" ]] || { echo "FAIL: no target// after build"; exit 1; } +before=$(ls "target/$triple" | wc -l) +[[ "$before" -eq 2 ]] || { echo "FAIL: expected 2 fingerprint dirs (dev+release), got $before: $(ls target/$triple)"; exit 1; } + +# A fingerprint directory nobody recorded, from long ago. +mkdir -p "target/$triple/deadbeefdeadbeef/bin" +echo stale > "target/$triple/deadbeefdeadbeef/bin/leftover" +touch -t 200001010000 "target/$triple/deadbeefdeadbeef" "target/$triple/deadbeefdeadbeef/bin/leftover" + +# An unrecorded directory written just now (what a `mcpp test` build looks +# like to the record): kept by the default --older-than 1d. +mkdir -p "target/$triple/cafef00dcafef00d" +echo latest > "target/$triple/cafef00dcafef00d/build.ninja" + +# --dry-run: names the old one, keeps the newest, deletes nothing. +out=$("$MCPP" clean --stale --dry-run 2>&1) +echo "$out" | grep -q 'would remove target/.*/deadbeefdeadbeef' || { echo "FAIL: dry-run did not list the stale dir: $out"; exit 1; } +echo "$out" | grep -q 'kept .*cafef00dcafef00d' || { echo "FAIL: dry-run should keep the freshly written unrecorded dir: $out"; exit 1; } +[[ -d "target/$triple/deadbeefdeadbeef" ]] || { echo "FAIL: dry-run deleted something"; exit 1; } +[[ $(ls "target/$triple" | wc -l) -eq 4 ]] || { echo "FAIL: dry-run changed target/"; exit 1; } + +# The real thing: exactly the old unrecorded one goes. +out=$("$MCPP" clean --stale 2>&1) +echo "$out" | grep -q 'removed target/.*/deadbeefdeadbeef' || { echo "FAIL: clean --stale did not report the removed dir: $out"; exit 1; } +[[ ! -d "target/$triple/deadbeefdeadbeef" ]] || { echo "FAIL: stale dir survived"; exit 1; } +[[ -d "target/$triple/cafef00dcafef00d" ]] || { echo "FAIL: fresh unrecorded dir was removed"; exit 1; } +[[ $(ls "target/$triple" | wc -l) -eq 3 ]] || { echo "FAIL: a current dir was removed: $(ls target/$triple)"; exit 1; } + +# --older-than 0 keeps nothing unrecorded; a bad duration is refused. +rc=0; out=$("$MCPP" clean --stale --older-than nonsense 2>&1) || rc=$? +[[ "$rc" -ne 0 ]] || { echo "FAIL: bad --older-than should be refused: $out"; exit 1; } +out=$("$MCPP" clean --stale --older-than 0 2>&1) +[[ ! -d "target/$triple/cafef00dcafef00d" ]] || { echo "FAIL: --older-than 0 kept the fresh unrecorded dir: $out"; exit 1; } +[[ $(ls "target/$triple" | wc -l) -eq 2 ]] || { echo "FAIL: --older-than 0 removed a current dir: $(ls target/$triple)"; exit 1; } + +# Nothing stale left: says so, changes nothing. +out=$("$MCPP" clean --stale 2>&1) +echo "$out" | grep -q 'Nothing stale' || { echo "FAIL: second pass should report nothing stale: $out"; exit 1; } +[[ $(ls "target/$triple" | wc -l) -eq 2 ]] || { echo "FAIL: second pass removed a current dir"; exit 1; } + +# --dry-run alone implies --stale (lists, deletes nothing); --stale with +# --bmi-cache is refused, since the global build cache has its own gc. +mkdir -p "target/$triple/feedfacefeedface" +touch -t 200001010000 "target/$triple/feedfacefeedface" +out=$("$MCPP" clean --dry-run 2>&1) +echo "$out" | grep -q 'feedfacefeedface' || { echo "FAIL: --dry-run alone did not list the stale dir: $out"; exit 1; } +[[ -d "target/$triple/feedfacefeedface" ]] || { echo "FAIL: --dry-run alone deleted"; exit 1; } +rc=0 +out=$("$MCPP" clean --stale --bmi-cache 2>&1) || rc=$? +[[ "$rc" -ne 0 ]] || { echo "FAIL: --stale --bmi-cache should be refused: $out"; exit 1; } +[[ -d "target/$triple/feedfacefeedface" ]] || { echo "FAIL: refused combination still deleted"; exit 1; } +"$MCPP" clean --stale > /dev/null + +# Survivors are intact: both profiles rebuild without relinking anything. +before=$(stat -c '%n %Y' target/"$triple"/*/bin/stale | sort) +"$MCPP" build > /dev/null +"$MCPP" build --release > /dev/null +after=$(stat -c '%n %Y' target/"$triple"/*/bin/stale | sort) +[[ "$before" == "$after" ]] || { echo "FAIL: a current directory was rebuilt after clean --stale"; echo "$before"; echo "$after"; exit 1; } + +echo "OK"