Skip to content

Commit 79e26ca

Browse files
yspbwx2010claude
andcommitted
feat(clean): --stale removes the fingerprint directories no recorded build still uses (#565)
Every build lands in target/<triple>/<fingerprint>/ and a changed fingerprint opens a fresh directory while the old one is never touched again; `mcpp clean` could only remove target/ wholesale. `mcpp clean --stale` treats the entries of target/.build_cache as current and removes every other fingerprint directory under the triples the record names, reporting each with its size; `--dry-run` lists and deletes nothing; with no record it refuses rather than guess; `--bmi-cache` is rejected alongside it. Unrecorded directories written within `--older-than` (default 1d) are kept, because `mcpp test` and `--no-cache` builds never write the record. Matching is by (triple, fingerprint) so a moved checkout is not read as all-stale, and directories outside the recorded triples (target/dist/) are left alone. The fingerprint-changed warning now points at the command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 893011b commit 79e26ca

7 files changed

Lines changed: 252 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
66
## [Unreleased]
77

8+
### `mcpp clean --stale`: 只清 target/ 里已无构建使用的指纹目录 (#565)
9+
10+
每次配置指纹变化都会在 `target/<三元组>/` 下新开一个目录, 旧目录从不回收; `mcpp clean` 只有整删一档,
11+
代价是全量重编, 于是没人跑. `mcpp clean --stale``target/.build_cache` 里记录的 (三元组, 指纹) 为
12+
当前, 删掉记录过的三元组目录下其余兄弟目录并报告各自体积; 未被记录但在 `--older-than` (默认 1d) 之内
13+
写过的目录保留 (`mcpp test` 的构建不写记录). `--dry-run` 只列出. 没有构建记录时拒绝执行而不是猜; 记录之外的目录
14+
(如 `mcpp pack``dist/`) 不碰. `fingerprint changed` 的警告末尾现在附带这条
15+
命令, 让增长可见.
16+
817
## [2026.9.5.3] — 2026-09-05
918

1019
### 官方构建插件集中为一个包:`mcpp:plugins`

docs/00-getting-started.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ human-readable.
131131
```bash
132132
mcpp build # incremental build
133133
mcpp clean # clean target/
134+
mcpp clean --stale # drop only target/<triple>/<fingerprint>/ dirs no build still uses
135+
# (--dry-run lists and deletes nothing; --older-than 3d keeps newer unrecorded ones)
134136
mcpp test # compile and run tests/**/*.cpp — one binary per file,
135137
# framework-agnostic (bare main, or gtest via [dev-dependencies])
136138
mcpp test <pattern> # only tests whose name contains <pattern>

docs/zh/00-getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ workspace 中运行。插件稳定依赖进程退出码和生成的 `compile_com
122122
```bash
123123
mcpp build # 增量构建
124124
mcpp clean # 清理 target/
125+
mcpp clean --stale # 只删 target/<三元组>/<指纹>/ 下已无构建使用的目录 (--dry-run 只列出; --older-than 3d 保留更新的未记录目录)
125126
mcpp test # 编译并运行 tests/**/*.cpp —— 每文件一个独立二进制,
126127
# 框架无关(裸 main,或经 [dev-dependencies] 使用 gtest)
127128
mcpp test <pattern> # 只运行名字包含 <pattern> 的测试

src/build/execute.cppm

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import mcpp.build.backend;
2525
import mcpp.build.ninja;
2626
import mcpp.build.runtime_validation;
2727
import mcpp.bmi_cache;
28+
import mcpp.bmi_cache.maintenance; // dir_size + human_bytes, for `clean --stale`
2829
import mcpp.manifest;
2930
import mcpp.source_kind;
3031
import mcpp.modgraph.scanner;
@@ -813,7 +814,8 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache,
813814
auto newFp = ctx.outputDir.filename().string();
814815
if (e.fingerprint != newFp) {
815816
mcpp::ui::warning(std::format(
816-
"fingerprint changed ({} → {}), full rebuild",
817+
"fingerprint changed ({} → {}), full rebuild; "
818+
"`mcpp clean --stale` drops the directories no build still uses",
817819
e.fingerprint, newFp));
818820
}
819821
break;
@@ -2568,4 +2570,126 @@ export int clean_project(bool wipe_bmi) {
25682570
return 0;
25692571
}
25702572

2573+
// `mcpp clean --stale` driver (#565).
2574+
//
2575+
// Every build lands in target/<triple>/<fingerprint>/, and a changed
2576+
// fingerprint opens a fresh directory while the old one is never touched
2577+
// again. "Current" is what target/.build_cache records: one entry per
2578+
// (target, profile) built recently, which is the set the fast paths and
2579+
// `mcpp run` still resolve to. Every other directory under a recorded
2580+
// target/<triple>/ is a leftover from a configuration that no longer exists
2581+
// and can go without forcing a rebuild of anything that does.
2582+
//
2583+
// Three deliberate limits, each erring toward deleting a directory that a
2584+
// rebuild can recreate rather than one that cannot:
2585+
// * Entries are matched by (triple directory, fingerprint), not by the
2586+
// absolute path the record stores, so a moved checkout is not read as
2587+
// "everything is stale".
2588+
// * Only triple directories named by the record are visited. Anything else
2589+
// under target/ (`dist/` from `mcpp pack`, whatever a later release adds)
2590+
// is not a fingerprint directory and is left alone — as is a triple whose
2591+
// entry has been evicted from the record; that one stays until it is
2592+
// built again.
2593+
// * The record keys on (target, profile) while the fingerprint also folds in
2594+
// features; a `--no-cache` build writes no entry at all; and `mcpp test`
2595+
// builds through run_tests, which never writes one. So an unrecorded
2596+
// directory is not proof of staleness, and recomputing fingerprints here
2597+
// is not an option (prepare_build resolves dependencies and may reach the
2598+
// network; a clean command must not). The guard that costs nothing and
2599+
// matches how `cache prune` already thinks: an unrecorded directory
2600+
// written within `--older-than` (default one day) is kept — that is the
2601+
// build somebody just ran. Older and unrecorded goes; the cost of being
2602+
// wrong there is one rebuild of a configuration nobody has touched since.
2603+
//
2604+
// With no record at all there is nothing to compare against, and the command
2605+
// refuses rather than guess.
2606+
export int clean_stale(bool dryRun, std::int64_t keepWithinSecs) {
2607+
namespace fs = std::filesystem;
2608+
auto root = mcpp::project::find_manifest_root(fs::current_path());
2609+
if (!root) { std::println(stderr, "error: not in an mcpp package"); return 2; }
2610+
const fs::path target = *root / "target";
2611+
2612+
std::set<std::pair<std::string, std::string>> current; // (triple dir, fingerprint)
2613+
std::set<std::string> currentTriples;
2614+
for (const auto& e : read_build_cache(*root)) {
2615+
const fs::path out(e.outputDir);
2616+
const std::string fp = e.fingerprint.empty() ? out.filename().string() : e.fingerprint;
2617+
const std::string triple = out.parent_path().filename().string();
2618+
if (fp.empty() || triple.empty()) continue;
2619+
current.emplace(triple, fp);
2620+
currentTriples.insert(triple);
2621+
}
2622+
if (current.empty()) {
2623+
std::println(stderr, "error: {} has no build record, so nothing is known to be current; "
2624+
"run `mcpp build` once, then retry",
2625+
(*root / kBuildCacheFile).string());
2626+
return 2;
2627+
}
2628+
2629+
std::uintmax_t bytes = 0;
2630+
std::size_t removed = 0, failed = 0;
2631+
std::error_code ec;
2632+
for (fs::directory_iterator tripleIt(target, ec), end; tripleIt != end; tripleIt.increment(ec)) {
2633+
std::error_code tec;
2634+
const std::string triple = tripleIt->path().filename().string();
2635+
if (!tripleIt->is_directory(tec) || tec || !currentTriples.contains(triple)) continue;
2636+
std::error_code iec;
2637+
for (fs::directory_iterator fpIt(tripleIt->path(), iec), fend; fpIt != fend; fpIt.increment(iec)) {
2638+
std::error_code fec;
2639+
if (!fpIt->is_directory(fec) || fec) continue;
2640+
const fs::path dir = fpIt->path();
2641+
const std::string fp = dir.filename().string();
2642+
if (current.contains({triple, fp})) continue;
2643+
const std::string shown = std::format("target/{}/{}", triple, fp);
2644+
// build.ninja is rewritten by every build; the directory's own
2645+
// mtime only moves when an entry is added or removed.
2646+
const auto stamp = dir / "build.ninja";
2647+
const auto written = fs::last_write_time(fs::exists(stamp, fec) ? stamp : dir, fec);
2648+
if (!fec) {
2649+
const auto age = std::chrono::duration_cast<std::chrono::seconds>(
2650+
fs::file_time_type::clock::now() - written).count();
2651+
if (age < keepWithinSecs) {
2652+
const std::string ago = age < 3600 ? std::format("{}m", std::max<std::int64_t>(age / 60, 1))
2653+
: age < 86400 ? std::format("{}h", age / 3600)
2654+
: std::format("{}d", age / 86400);
2655+
std::println("kept {} (not in the record, but written {} ago; see --older-than)", shown, ago);
2656+
continue;
2657+
}
2658+
}
2659+
const auto size = mcpp::bmi_cache::dir_size(dir);
2660+
if (dryRun) {
2661+
std::println("would remove {} ({})", shown, mcpp::bmi_cache::human_bytes(size));
2662+
} else {
2663+
std::error_code rec;
2664+
fs::remove_all(dir, rec);
2665+
if (rec) {
2666+
std::println(stderr, "error: cannot remove {}: {}", shown, rec.message());
2667+
++failed;
2668+
continue;
2669+
}
2670+
std::println("removed {} ({})", shown, mcpp::bmi_cache::human_bytes(size));
2671+
}
2672+
bytes += size;
2673+
++removed;
2674+
}
2675+
if (iec) {
2676+
std::println(stderr, "error: cannot read {}: {}", tripleIt->path().string(), iec.message());
2677+
++failed;
2678+
}
2679+
}
2680+
if (ec) {
2681+
std::println(stderr, "error: cannot read {}: {}", target.string(), ec.message());
2682+
return 1;
2683+
}
2684+
2685+
if (removed == 0 && failed == 0) {
2686+
std::println("Nothing stale under {}: every fingerprint directory is recorded as current",
2687+
target.string());
2688+
} else {
2689+
std::println("{} {} director{} ({})", dryRun ? "Would remove" : "Removed", removed,
2690+
removed == 1 ? "y" : "ies", mcpp::bmi_cache::human_bytes(bytes));
2691+
}
2692+
return failed ? 1 : 0;
2693+
}
2694+
25712695
} // namespace mcpp::build

src/cli.cppm

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ void print_usage() {
6161
std::println(" mcpp build [options] Build the current package");
6262
std::println(" mcpp run [target] [-- args...] Build + run a binary target");
6363
std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json, --no-runner)");
64-
std::println(" mcpp clean [--bmi-cache] Remove target/ (and optionally the build cache)");
64+
std::println(" mcpp clean [--stale] [--bmi-cache] Remove target/ (or, with --stale, only its non-current fingerprint dirs)");
6565
std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml");
6666
std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml");
6767
std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock");
@@ -506,8 +506,12 @@ int run(int argc, char** argv) {
506506
return cmd_test(p, std::span<const std::string>(passthrough));
507507
})))
508508
.subcommand(cl::App("clean")
509-
.description("Remove target/ (and optionally the global build cache)")
509+
.description("Remove target/, or with --stale only the fingerprint directories under it that no recorded build still uses")
510510
.option(cl::Option("bmi-cache").help("Also wipe the global build cache (see `mcpp cache clean`)"))
511+
.option(cl::Option("stale").help("Only remove target/<triple>/<fingerprint>/ directories that no recorded build considers current"))
512+
.option(cl::Option("dry-run").help("List what would be removed and delete nothing (implies --stale)"))
513+
.option(cl::Option("older-than").takes_value().value_name("DURATION")
514+
.help("With --stale: keep unrecorded directories written more recently than this, e.g. 12h, 3d (default 1d; 0 keeps none)"))
511515
.action(wrap_rc(cmd_clean)))
512516
.subcommand(cl::App("why")
513517
.description("Explain how the toolchain / runtime / deps / runners were resolved")

src/cli/cmd_build.cppm

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import std;
1212
import mcpplibs.cmdline;
1313
import mcpp.build.prepare;
1414
import mcpp.build.execute;
15+
import mcpp.bmi_cache.maintenance; // parse_duration, for `clean --stale --older-than`
1516
import mcpp.build.directives; // the device-slot table
1617
import mcpp.build.configure;
1718
import mcpp.build.coff_exports;
@@ -463,6 +464,24 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
463464
}
464465

465466
export int cmd_clean(const mcpplibs::cmdline::ParsedArgs& parsed) {
467+
const bool dryRun = parsed.is_flag_set("dry-run");
468+
if (parsed.is_flag_set("stale") || dryRun) {
469+
if (parsed.is_flag_set("bmi-cache")) {
470+
std::println(stderr, "error: --stale/--dry-run cannot be combined with --bmi-cache "
471+
"(the build cache is shared across projects; use `mcpp cache gc`)");
472+
return 2;
473+
}
474+
std::int64_t keepWithinSecs = 24 * 3600;
475+
if (auto v = parsed.value("older-than")) {
476+
auto secs = (*v == "0") ? std::optional<std::int64_t>{0} : mcpp::bmi_cache::parse_duration(*v);
477+
if (!secs) {
478+
std::println(stderr, "error: invalid --older-than '{}' (expected <N>s, <N>m, <N>h, <N>d, or 0)", *v);
479+
return 2;
480+
}
481+
keepWithinSecs = *secs;
482+
}
483+
return mcpp::build::clean_stale(dryRun, keepWithinSecs);
484+
}
466485
return mcpp::build::clean_project(parsed.is_flag_set("bmi-cache"));
467486
}
468487

tests/e2e/609_clean_stale.sh

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# 609_clean_stale.sh — `mcpp clean --stale` (#565): under target/<triple>/,
4+
# only fingerprint directories that no recorded build considers current are
5+
# removed. A dev and a release build are both current (two fingerprints, two
6+
# entries in target/.build_cache); a directory nobody recorded is stale.
7+
# --dry-run lists and deletes nothing; a plain `mcpp clean --stale` deletes
8+
# exactly the old stale one, keeps a freshly written unrecorded one (a
9+
# `mcpp test` build looks like that) unless --older-than 0, and the survivors
10+
# are not rebuilt afterwards.
11+
set -e
12+
13+
TMP=$(mktemp -d)
14+
trap "rm -rf $TMP" EXIT
15+
16+
cd "$TMP"
17+
"$MCPP" new stale > /dev/null
18+
cd stale
19+
20+
# Refuses before any build: there is no record of what is current.
21+
rc=0
22+
out=$("$MCPP" clean --stale 2>&1) || rc=$?
23+
[[ "$rc" -ne 0 ]] || { echo "FAIL: --stale before any build should refuse: $out"; exit 1; }
24+
echo "$out" | grep -q 'build_cache' || { echo "FAIL: refusal should name the record: $out"; exit 1; }
25+
26+
"$MCPP" build > /dev/null
27+
"$MCPP" build --release > /dev/null
28+
29+
triple=$(ls target | grep -v '^\.' | head -1)
30+
[[ -n "$triple" ]] || { echo "FAIL: no target/<triple>/ after build"; exit 1; }
31+
before=$(ls "target/$triple" | wc -l)
32+
[[ "$before" -eq 2 ]] || { echo "FAIL: expected 2 fingerprint dirs (dev+release), got $before: $(ls target/$triple)"; exit 1; }
33+
34+
# A fingerprint directory nobody recorded, from long ago.
35+
mkdir -p "target/$triple/deadbeefdeadbeef/bin"
36+
echo stale > "target/$triple/deadbeefdeadbeef/bin/leftover"
37+
touch -t 200001010000 "target/$triple/deadbeefdeadbeef" "target/$triple/deadbeefdeadbeef/bin/leftover"
38+
39+
# An unrecorded directory written just now (what a `mcpp test` build looks
40+
# like to the record): kept by the default --older-than 1d.
41+
mkdir -p "target/$triple/cafef00dcafef00d"
42+
echo latest > "target/$triple/cafef00dcafef00d/build.ninja"
43+
44+
# --dry-run: names the old one, keeps the newest, deletes nothing.
45+
out=$("$MCPP" clean --stale --dry-run 2>&1)
46+
echo "$out" | grep -q 'would remove target/.*/deadbeefdeadbeef' || { echo "FAIL: dry-run did not list the stale dir: $out"; exit 1; }
47+
echo "$out" | grep -q 'kept .*cafef00dcafef00d' || { echo "FAIL: dry-run should keep the freshly written unrecorded dir: $out"; exit 1; }
48+
[[ -d "target/$triple/deadbeefdeadbeef" ]] || { echo "FAIL: dry-run deleted something"; exit 1; }
49+
[[ $(ls "target/$triple" | wc -l) -eq 4 ]] || { echo "FAIL: dry-run changed target/"; exit 1; }
50+
51+
# The real thing: exactly the old unrecorded one goes.
52+
out=$("$MCPP" clean --stale 2>&1)
53+
echo "$out" | grep -q 'removed target/.*/deadbeefdeadbeef' || { echo "FAIL: clean --stale did not report the removed dir: $out"; exit 1; }
54+
[[ ! -d "target/$triple/deadbeefdeadbeef" ]] || { echo "FAIL: stale dir survived"; exit 1; }
55+
[[ -d "target/$triple/cafef00dcafef00d" ]] || { echo "FAIL: fresh unrecorded dir was removed"; exit 1; }
56+
[[ $(ls "target/$triple" | wc -l) -eq 3 ]] || { echo "FAIL: a current dir was removed: $(ls target/$triple)"; exit 1; }
57+
58+
# --older-than 0 keeps nothing unrecorded; a bad duration is refused.
59+
rc=0; out=$("$MCPP" clean --stale --older-than nonsense 2>&1) || rc=$?
60+
[[ "$rc" -ne 0 ]] || { echo "FAIL: bad --older-than should be refused: $out"; exit 1; }
61+
out=$("$MCPP" clean --stale --older-than 0 2>&1)
62+
[[ ! -d "target/$triple/cafef00dcafef00d" ]] || { echo "FAIL: --older-than 0 kept the fresh unrecorded dir: $out"; exit 1; }
63+
[[ $(ls "target/$triple" | wc -l) -eq 2 ]] || { echo "FAIL: --older-than 0 removed a current dir: $(ls target/$triple)"; exit 1; }
64+
65+
# Nothing stale left: says so, changes nothing.
66+
out=$("$MCPP" clean --stale 2>&1)
67+
echo "$out" | grep -q 'Nothing stale' || { echo "FAIL: second pass should report nothing stale: $out"; exit 1; }
68+
[[ $(ls "target/$triple" | wc -l) -eq 2 ]] || { echo "FAIL: second pass removed a current dir"; exit 1; }
69+
70+
# --dry-run alone implies --stale (lists, deletes nothing); --stale with
71+
# --bmi-cache is refused, since the global build cache has its own gc.
72+
mkdir -p "target/$triple/feedfacefeedface"
73+
touch -t 200001010000 "target/$triple/feedfacefeedface"
74+
out=$("$MCPP" clean --dry-run 2>&1)
75+
echo "$out" | grep -q 'feedfacefeedface' || { echo "FAIL: --dry-run alone did not list the stale dir: $out"; exit 1; }
76+
[[ -d "target/$triple/feedfacefeedface" ]] || { echo "FAIL: --dry-run alone deleted"; exit 1; }
77+
rc=0
78+
out=$("$MCPP" clean --stale --bmi-cache 2>&1) || rc=$?
79+
[[ "$rc" -ne 0 ]] || { echo "FAIL: --stale --bmi-cache should be refused: $out"; exit 1; }
80+
[[ -d "target/$triple/feedfacefeedface" ]] || { echo "FAIL: refused combination still deleted"; exit 1; }
81+
"$MCPP" clean --stale > /dev/null
82+
83+
# Survivors are intact: both profiles rebuild without relinking anything.
84+
before=$(stat -c '%n %Y' target/"$triple"/*/bin/stale | sort)
85+
"$MCPP" build > /dev/null
86+
"$MCPP" build --release > /dev/null
87+
after=$(stat -c '%n %Y' target/"$triple"/*/bin/stale | sort)
88+
[[ "$before" == "$after" ]] || { echo "FAIL: a current directory was rebuilt after clean --stale"; echo "$before"; echo "$after"; exit 1; }
89+
90+
echo "OK"

0 commit comments

Comments
 (0)