Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions docs/00-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ human-readable.
```bash
mcpp build # incremental build
mcpp clean # clean target/
mcpp clean --stale # drop only target/<triple>/<fingerprint>/ 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 <pattern> # only tests whose name contains <pattern>
Expand Down
1 change: 1 addition & 0 deletions docs/zh/00-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pattern> # 只运行名字包含 <pattern> 的测试
Expand Down
126 changes: 125 additions & 1 deletion src/build/execute.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2568,4 +2570,126 @@ export int clean_project(bool wipe_bmi) {
return 0;
}

// `mcpp clean --stale` driver (#565).
//
// Every build lands in target/<triple>/<fingerprint>/, 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/<triple>/ 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<std::pair<std::string, std::string>> current; // (triple dir, fingerprint)
std::set<std::string> 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<std::chrono::seconds>(
fs::file_time_type::clock::now() - written).count();
if (age < keepWithinSecs) {
const std::string ago = age < 3600 ? std::format("{}m", std::max<std::int64_t>(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
8 changes: 6 additions & 2 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -506,8 +506,12 @@ int run(int argc, char** argv) {
return cmd_test(p, std::span<const std::string>(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/<triple>/<fingerprint>/ 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")
Expand Down
19 changes: 19 additions & 0 deletions src/cli/cmd_build.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::int64_t>{0} : mcpp::bmi_cache::parse_duration(*v);
if (!secs) {
std::println(stderr, "error: invalid --older-than '{}' (expected <N>s, <N>m, <N>h, <N>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"));
}

Expand Down
90 changes: 90 additions & 0 deletions tests/e2e/609_clean_stale.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# requires:
# 609_clean_stale.sh — `mcpp clean --stale` (#565): under target/<triple>/,
# 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/<triple>/ 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"
Loading