feat(prune): R2 dry-run prune shell + persist wiring, report-only

Add ReaSamplerSession::pruneDryRun enumerating the resolved current bank
folder, feeding the R1 core, and returning a PruneReport (count/bytes/list).
New pure bankRelativeForName + buildPruneReport keep spelling and tally
testable. Register forever-stable BANK_PRUNE_FOLDER action, report-only. No deletion.
This commit is contained in:
2026-07-26 18:19:41 -04:00
parent 46176f3f04
commit c1473c42e1
10 changed files with 297 additions and 1 deletions
+38
View File
@@ -451,6 +451,10 @@ constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED";
constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED";
constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT";
constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT";
// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so
// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion),
// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id.
constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER";
int g_cmdBankCreate = 0;
int g_cmdBankRename = 0;
@@ -463,6 +467,7 @@ int g_cmdBankCopySel = 0;
int g_cmdBankRemoveSel = 0;
int g_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 0;
int g_cmdBankPruneFolder = 0;
gaccel_register_t g_accelBankCreate{};
gaccel_register_t g_accelBankRename{};
@@ -475,6 +480,7 @@ gaccel_register_t g_accelBankCopySel{};
gaccel_register_t g_accelBankRemoveSel{};
gaccel_register_t g_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{};
gaccel_register_t g_accelBankPruneFolder{};
// Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp
// RunCapture), NOT the Design-View path: a bank change is held in-session and written
@@ -791,6 +797,31 @@ void doBankRemoveSelected() {
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
}
// Prune bank folder — Phase R (Reclaim), R2: REPORT-ONLY dry run. Asks the session for
// the orphan set of the resolved CURRENT bank folder ((owned ∩ present) referenced,
// unioned across every bank) and prints the truthful reclaim report — count, reclaimable
// bytes, and the file list. DELETES NOTHING, writes no ext-state, opens no undo point
// (pruneDryRun is read-only across the persist seam). R3 extends this SAME action id to
// confirm-and-delete behind the dry-run guardrail; the report path here is what R3 wraps.
void doBankPruneFolder() {
const PruneReport report = g_session->pruneDryRun();
if (report.count == 0) {
ShowConsoleMsg("ReaSampler prune (dry run): no orphaned files to reclaim.\n");
return;
}
std::string msg = "ReaSampler prune (dry run): " + std::to_string(report.count) +
" orphaned file(s), " + std::to_string(report.totalBytes) +
" bytes reclaimable. (Dry run -- nothing deleted.)\n";
for (const std::string& rel : report.orphans) msg += " " + rel + "\n";
if (report.truncated) {
msg += " ... (" + std::to_string(report.count - report.orphans.size()) +
" more not shown)\n";
}
ShowConsoleMsg(msg.c_str());
}
} // namespace
// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
@@ -851,6 +882,10 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
"toggle pool full-height");
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
"toggle banks full-height");
// Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends
// the confirm-and-delete step behind this SAME forever-stable id).
g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder,
"prune bank folder");
}
bool bankHandleCommand(int command) {
@@ -867,6 +902,7 @@ bool bankHandleCommand(int command) {
if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; }
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
if (command == g_cmdBankPruneFolder) { doBankPruneFolder(); return true; }
return false; // not ours — caller's hookcommand keeps looking
}
@@ -874,6 +910,8 @@ bool bankHandleCommand(int command) {
void bankUnregisterActions(reaper_plugin_info_t* rec) {
// Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
// '-command_id' re-presents the same interned channel-qualified id (channelIdFor).
rec->Register("-gaccel", (void*)&g_accelBankPruneFolder);
rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder));
rec->Register("-gaccel", (void*)&g_accelBankBanksFull);
rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull));
rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
+7
View File
@@ -191,6 +191,13 @@ BankPaths deriveBankPaths(const std::string& projectDir,
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName;
}
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
+12
View File
@@ -92,6 +92,18 @@ BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
// "/" + fileName), so a file the capture path created and a directory listing of that
// same file resolve to the byte-identical relative string — the safety-critical
// spelling-consistency the prune core's exact-string match depends on (a divergence
// here could make a referenced file look like an orphan). fileName is a bare entry
// name (no directory component); the caller supplies forward-slash-free names from the
// folder enumeration. Empty in -> empty out.
std::string bankRelativeForName(const std::string& fileName);
// --- Persist-side path arithmetic (M4) --------------------------------------
//
// The index stores relative paths only; on project load the persist shell must
+64
View File
@@ -77,10 +77,13 @@
#include <filesystem>
#include <string>
#include <system_error>
#include <unordered_map>
#include <vector>
#include "app_version.h"
#include "capture_paths.h"
#include "prune_reconcile.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -243,6 +246,67 @@ bool ReaSamplerSession::saveToActiveProject() {
namespace {
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
// exact (tallied over the full orphan set), but the enumerated file list handed to the
// console is clipped to this many entries so a project with thousands of orphans does
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
constexpr std::size_t kPruneListDisplayCap = 64;
} // namespace
PruneReport ReaSamplerSession::pruneDryRun() const {
PruneReport report;
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj || rppPath.empty()) return report; // no active/saved project -> nothing
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
const std::string projectDir = projectDirOf(rppPath);
const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder);
if (bankDir.empty()) return report; // unresolvable (no project dir) -> nothing
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
return report; // no bank folder captured yet -> nothing to reclaim
}
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
// convention) so the pure core's exact-string match lines up with referencedPaths()
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
// path so the report's byte tally reuses the same on-disk read.
std::vector<std::string> present;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
for (const auto& entry : fs::directory_iterator(bankDir, ec)) {
if (ec) break;
std::error_code fec;
if (!entry.is_regular_file(fec)) continue; // skip subdirs / specials
const std::string name = entry.path().filename().string();
const std::string rel = bankRelativeForName(name);
if (rel.empty()) continue;
present.push_back(rel);
const std::uintmax_t sz = entry.file_size(fec);
sizeByRel[rel] = fec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the session's book and
// manifest (NO save, NO MarkProjectDirty, NO mutation). referencedPaths() unions
// across the whole book (pool included); owned().paths() is the manifest set. The
// count / byte-sum / display-truncation tally is the pure buildPruneReport, so this
// shell only enumerates, resolves, and stats — no report logic re-implemented here.
const std::vector<std::string> orphans =
pruneOrphans(present, book_.referencedPaths(), owned_.paths());
return buildPruneReport(orphans, sizeByRel, kPruneListDisplayCap);
}
namespace {
// Load the Design-View model from a project's view_state key, or return a fresh
// default. An absent/empty key (older project with no view state) yields a
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
+16
View File
@@ -23,6 +23,7 @@
#include "bank_book.h"
#include "bank_model.h"
#include "owned_manifest.h"
#include "prune_reconcile.h"
#include "tail_control.h"
#include "view_mode_model.h"
@@ -179,6 +180,21 @@ public:
// no dangling no-effect undo entry is opened on an unsaved project.
bool saveToActiveProject();
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
// relative machinery the index/persist use — never a stale absolute path, so it is
// correct across a Save-As relocation), spells every enumerated entry with the index's
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
// and feeds the R1 pure core with (present, book().referencedPaths(), owned().paths()).
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
// list. The decision stays in the pure core — this method only enumerates, resolves,
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
//
// Yields an empty report (count 0) when there is no active/saved project or no bank
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
PruneReport pruneDryRun() const;
// Poll the active project. Detects a project load (active project changed)
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
+19
View File
@@ -33,4 +33,23 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
return orphans;
}
PruneReport buildPruneReport(
const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap) {
PruneReport report;
report.count = orphans.size();
for (const std::string& o : orphans) {
// Sum EVERY orphan's bytes (the exact reclaimable total), not just the displayed
// ones. A path with no stat'd size contributes 0 — never dropped, never negative.
const auto it = sizeByPath.find(o);
report.totalBytes += (it != sizeByPath.end()) ? it->second : 0;
// Display list is capped (0 = uncapped). count stays exact above regardless.
if (displayCap == 0 || report.orphans.size() < displayCap)
report.orphans.push_back(o);
}
report.truncated = report.orphans.size() < report.count;
return report;
}
} // namespace reasampler
+45
View File
@@ -41,11 +41,36 @@
// match here (e.g. case-insensitive compare) would be the unsafe direction — it could
// let one spelling of a referenced file be treated as an orphan under another.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace reasampler {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands
// it to the report surface; R3 will act on the SAME set behind the confirm guardrail.
// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the
// tallied outcome), so the count/size aggregation is unit-testable outside the DAW.
//
// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally,
// exact even when `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable
// space). A file the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list as project-relative index-spelled paths, in
// folder-enumeration order (deterministic). MAY be truncated for a large
// set (the shell's display cap); `count` stays exact regardless, and
// `truncated` says whether the list was clipped.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
struct PruneReport {
std::size_t count = 0;
std::uint64_t totalBytes = 0;
std::vector<std::string> orphans;
bool truncated = false;
};
// Computes the prune orphan set: (owned ∩ present) referenced.
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
@@ -60,4 +85,24 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup.
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`;
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
//
// * count == orphans.size() (the authoritative tally, exact regardless of the cap).
// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed
// ones); a path missing from sizeByPath contributes 0 (an orphan whose
// size could not be stat'd — never negative, never dropped from the sum).
// * orphans == the first `displayCap` orphans in input order (the deterministic
// folder-enumeration order pruneOrphans preserved); the whole set when
// count <= displayCap. displayCap == 0 means "no display cap" (whole set).
// * truncated == count > orphans.size() (a large set was clipped for display).
//
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function
// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both
// need) is its own small, testable step.
PruneReport buildPruneReport(const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap);
} // namespace reasampler