From e5a22c6b1aa77fb18982902c6d6d19f7abfeaf09 Mon Sep 17 00:00:00 2001
From: helly25 <6420169+helly25@users.noreply.github.com>
Date: Tue, 15 Sep 2026 21:15:41 +0100
Subject: [PATCH 1/4] Add combined, per-root, and comparison summary scopes
---
CHANGELOG.md | 5 +
XFF.md | 37 +++-
docs/design-summary-scopes.md | 38 ++++
xff/cli/globals.cc | 40 +++-
xff/cli/help_build.cc | 7 +-
xff/cli/summary_test.sh | 68 ++++++
xff/engine/run.cc | 401 +++++++++++++++++++++++++---------
7 files changed, 486 insertions(+), 110 deletions(-)
create mode 100644 docs/design-summary-scopes.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ea5a0b48f..179c881a29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,11 @@
+# 0.6.0
+
+- Add `--summary-scope` for combined, per-root, and comparison-category statistics, with
+ ordered aliases and separate left/right statistics for paired categories.
+
# 0.5.0
- Expand at most one related topic in focused flag help, with explicit selections checked at
diff --git a/XFF.md b/XFF.md
index 63bb9f7b98..ae3d258eb8 100644
--- a/XFF.md
+++ b/XFF.md
@@ -802,7 +802,7 @@ See also: [Configuration](#topic-config), [Archives](#topic-archive), [Output](#
- `status` - one tab-separated selected result kind and relative path per record
- `diff` - a unified tree diff suitable for saving as a patch
- Requires exactly two roots. `--compare=summary` expands at its position to `--compare=status --compare-select=none --summary=compare`; later flags may override its settings. Bare `--compare` and `--compare=status` emit only discrepancies as tab-separated `left-only`, `right-only`, or `different` records. `--compare=diff` emits one unified tree diff, suitable for redirecting to a patch file; `--diff-context` and `--diff-algorithm` tune it. Unlike `-diff TARGET`, which is an expression action comparing each match from one walk with a templated target and therefore cannot discover target-only paths, `--compare` walks both roots independently and pairs the matches by relative path. The ordinary expression, ignore, hidden-file, archive, traversal, and `-P` / `-H` / `-L` symlink rules apply unchanged to each side; comparison itself enables none of them. Regular files are compared byte for byte (text and binary). Unfollowed symlinks are compared by target. Both walks complete before comparison records are emitted in bytewise relative-path order; `--sort` affects each walk, not that final order. In status mode, `--path-encoding=escape` makes control bytes in the relative path unambiguous. `--summary` / `--summary=compare` append comparison counts and percentages by status and a total. Other summary groupings still describe each input tree separately.
+ Requires exactly two roots. `--compare=summary` expands at its position to `--compare=status --compare-select=none --summary=compare`; later flags may override its settings. Bare `--compare` and `--compare=status` emit only discrepancies as tab-separated `left-only`, `right-only`, or `different` records. `--compare=diff` emits one unified tree diff, suitable for redirecting to a patch file; `--diff-context` and `--diff-algorithm` tune it. Unlike `-diff TARGET`, which is an expression action comparing each match from one walk with a templated target and therefore cannot discover target-only paths, `--compare` walks both roots independently and pairs the matches by relative path. The ordinary expression, ignore, hidden-file, archive, traversal, and `-P` / `-H` / `-L` symlink rules apply unchanged to each side; comparison itself enables none of them. Regular files are compared byte for byte (text and binary). Unfollowed symlinks are compared by target. Both walks complete before comparison records are emitted in bytewise relative-path order; `--sort` affects each walk, not that final order. In status mode, `--path-encoding=escape` makes control bytes in the relative path unambiguous. `--summary` / `--summary=compare` append comparison counts and percentages by status and a total. Other summary groupings combine the input trees; `--summary-scope` separates roots or comparison categories.
Affected by: --compare-select, --diff-algorithm, --diff-context, --summary-precision
See also: [Comparing trees](#topic-compare), [--compare-select](#flag-compare-select), [--diff-algorithm](#flag-diff-algorithm), [--diff-context](#flag-diff-context), [--summary-precision](#flag-summary-precision)
@@ -946,6 +946,26 @@ See also: [Configuration](#topic-config), [Archives](#topic-archive), [Output](#
### Statistics
+
+
+- `--summary-scope=SCOPE,...` - summarize all roots together, each root, or selected comparison categories _(global, xff)_
+ One of:
+
+ - `all` - combine all input roots
+ - `root` - separate input roots
+ - `left-only` - comparison entries present only on the left
+ - `right-only` - comparison entries present only on the right
+ - `different` - different comparison pairs, with each side separate
+ - `identical` - identical comparison pairs, with each side separate
+ - `left` - alias for `left-only`
+ - `right` - alias for `right-only`
+ - `diff` - `left-only,right-only,different`
+ - `compare` - `left-only,right-only,different,identical`
+
+ Selects independent labelled tables for each ordinary `--summary` grouping. `all` combines every root (the default); `root` separates input roots. Comparison categories require `--compare`: `left` aliases `left-only`, `right` aliases `right-only`, `diff` expands to `left-only,right-only,different`, and `compare` expands to `left-only,right-only,different,identical`. Aliases expand in order and duplicate scopes are removed. Repeating the flag replaces the preceding list. Selection is independent of `--compare-select`. Paired categories preserve separate left and right statistics; `all` counts both copies. This flag does not enable summaries or change `--summary=compare`.
+ Affects: --summary
+ See also: [Statistics](#topic-stats), [Comparing trees](#topic-compare), [--summary](#flag-summary)
+
- `--summary[=]` - aligned count + size table (or --format=jsonl rows) instead of each match; repeatable _(global, xff)_
@@ -964,9 +984,9 @@ See also: [Configuration](#topic-config), [Archives](#topic-archive), [Output](#
- `hash-verification` - verified / failed tally from exactly one reached `-hasheq`
- `{template}` - by any field value, e.g. `--summary='{ext}-{type}'`
- With `--compare`, bare `--summary` or `--summary=compare` appends result counts and percentages by status and a total after the comparison output. Other groupings summarize each input tree. Outside comparison, replaces the per-match listing with an aggregate table: match count and total size per group (overall, by type, extension, programming language, media (MIME) type, user (owner), owning group, file digest, or hash-verification result). The categorical keys reuse the {mime}/{user}/{group}/{hash} field vocabulary; --summary=hash groups identical files into one bucket (a dedup count, reading every file). `--summary=hash-verification` requires exactly one `-hasheq` and counts its `verified` or `failed` verdict even when that verdict makes the complete expression false; an entry that short-circuits before reaching `-hasheq` is not counted. Empty expected values and unreadable entries are failed, matching `-hasheq` itself. A {template} key groups by any field value (e.g. --summary='{ext}-{type}'); a single m// extraction key (--summary='{capture.NAME:m/re/\1/}') groups per extracted line, so a per-file command's multi-line output tallies per key (e.g. git-blame lines per author) - the size column is not meaningful there. Repeatable: each --summary is its own table (e.g. --summary=ext --summary=type), printed in order. --top=N limits the rows of each, --summary-precision sets the scaled-size digits, and --format=jsonl emits one object per group for scripts.
- Affected by: --top, --summary-precision
- See also: [Statistics](#topic-stats), [--top](#flag-top), [--summary-precision](#flag-summary-precision)
+ With `--compare`, bare `--summary` or `--summary=compare` appends result counts and percentages by status and a total after the comparison output. With explicit `--summary-scope`, bare `--summary` also adds ordinary count and size statistics. Other groupings combine all input roots by default. `--summary-scope` selects combined, per-root, or comparison-category tables. Outside comparison, replaces the per-match listing with an aggregate table: match count and total size per group (overall, by type, extension, programming language, media (MIME) type, user (owner), owning group, file digest, or hash-verification result). The categorical keys reuse the {mime}/{user}/{group}/{hash} field vocabulary; --summary=hash groups identical files into one bucket (a dedup count, reading every file). `--summary=hash-verification` requires exactly one `-hasheq` and counts its `verified` or `failed` verdict even when that verdict makes the complete expression false; an entry that short-circuits before reaching `-hasheq` is not counted. Empty expected values and unreadable entries are failed, matching `-hasheq` itself. A {template} key groups by any field value (e.g. --summary='{ext}-{type}'); a single m// extraction key (--summary='{capture.NAME:m/re/\1/}') groups per extracted line, so a per-file command's multi-line output tallies per key (e.g. git-blame lines per author) - the size column is not meaningful there. Repeatable: each --summary is its own table (e.g. --summary=ext --summary=type), printed in order. --top=N limits the rows of each, --summary-precision sets the scaled-size digits, and --format=jsonl emits one object per group for scripts.
+ Affected by: --summary-scope, --top, --summary-precision
+ See also: [Statistics](#topic-stats), [--summary-scope](#flag-summary-scope), [--top](#flag-top), [--summary-precision](#flag-summary-precision)
@@ -2712,7 +2732,7 @@ The two walks run concurrently and each retains its complete matched-entry inven
Bare `--compare` (or `--compare=status`) writes tab-separated `STATUS` and relative-path records. The default selection reports discrepancies only; `--compare-select=all` also includes equal entries. `--compare-select=none` (or an empty value) suppresses per-path output without changing summary counts. `--path-encoding=escape` makes control bytes in the path unambiguous.
-`--summary` (or `--summary=compare`) appends counts and percentages of all comparison results by status and a total, including zero counts for empty comparisons. Each paired path counts once; directory-only entries are omitted as in the status listing. Percentages use all compared results; `--summary-precision` controls decimals. `--format=jsonl` renders these summary rows as `group`, `count`, and numeric `percent` objects. Other summary groupings describe each input tree separately.
+`--summary` (or `--summary=compare`) appends counts and percentages of all comparison results by status and a total, including zero counts for empty comparisons. Each paired path counts once; directory-only entries are omitted as in the status listing. Percentages use all compared results; `--summary-precision` controls decimals. `--format=jsonl` renders these summary rows as `group`, `count`, and numeric `percent` objects. Other summary groupings combine the input trees. `--summary-scope=root` separates roots; `--summary-scope=compare` separates all comparison categories, preserving both sides of paired results.
`--compare=summary` is shorthand for `--compare=status --compare-select=none --summary=compare` at that position in the option sequence. Later selections can enable per-path output, and `--summary=none` can disable the summary.
@@ -2729,6 +2749,7 @@ Bare `--compare` (or `--compare=status`) writes tab-separated `STATUS` and relat
- `--summary=compare`: counts and percentages for all comparison results.
- `--compare-select`: choose per-path records; `none` suppresses the listing.
+- `--summary-scope`: combined, per-root, or category statistics.
- `--summary-precision`: decimal places in summary percentages.
- `--format=jsonl`: machine-readable comparison summary rows.
@@ -2738,6 +2759,12 @@ Bare `--compare` (or `--compare=status`) writes tab-separated `STATUS` and relat
xff --compare=summary left-tree right-tree
```
+```sh
+xff --compare=summary left-tree right-tree --summary=ext --summary-scope=compare
+```
+
+summarize extensions within each comparison category
+
show only counts and percentages, with no per-path records
```sh
diff --git a/docs/design-summary-scopes.md b/docs/design-summary-scopes.md
new file mode 100644
index 0000000000..bafe908ca7
--- /dev/null
+++ b/docs/design-summary-scopes.md
@@ -0,0 +1,38 @@
+
+
+
+# Summary scopes
+
+`--summary` selects statistics; `--summary-scope` selects their populations. The default
+`all` combines entries across roots. `root` emits a labelled set of tables for each root.
+Neither setting deduplicates files across roots.
+
+Comparison scopes require `--compare`. `left` and `right` alias `left-only` and `right-only`.
+`diff` expands to `left-only,right-only,different`; `compare` additionally includes `identical`.
+Lists expand in order and discard duplicate scopes. A repeated flag replaces the preceding list.
+`all` and `root` may be mixed with comparison categories; those populations intentionally overlap.
+
+Paired categories produce separate tables for the left and right root, so different sizes and
+metadata remain attributable. Comparison counts count pairs once, while ordinary combined
+statistics count both entries. Category tables use the same classification as comparison output,
+including its directory exclusions. Output selection (`--compare-select`) does not filter statistics.
+
+`--summary-scope` never enables a summary. In comparison mode, bare `--summary` still requests
+comparison counts; with an explicit scope it also requests ordinary count and size statistics.
+`--summary=compare` itself remains a whole-comparison count and percentage table.
+
+```sh
+xff ONE TWO THREE --summary=ext --summary-scope=root
+xff --compare=summary LEFT RIGHT --summary=ext --summary-scope=compare
+xff --compare LEFT RIGHT --summary --summary-scope=diff
+```
+
+Accumulation retains the existing archive, collection, shard, and verification feeds. Combined
+and root scopes aggregate during traversal. Category scopes retain per-entry contributions until
+pairing assigns categories, without replaying expressions or executing actions again. Entries
+without a comparison category do not enter category tables, even if a special reduction feed
+(such as failed hash verification) counted them in a whole-root table.
+
+Plain output labels scopes and roots. JSONL rows carry `scope` and `root` fields when scopes are
+explicit, and in comparison mode. Empty populations produce a zero total. Sorting, top limits,
+precision, templates, and size formatting apply separately to each table.
diff --git a/xff/cli/globals.cc b/xff/cli/globals.cc
index a930b7f182..5849bb8125 100644
--- a/xff/cli/globals.cc
+++ b/xff/cli/globals.cc
@@ -94,6 +94,19 @@ constexpr std::array kFormatValues = std::to_array({
{.value = "tree", .meaning = "an indented directory tree"},
{.value = "md", .meaning = "", .hidden = true}, // the alias `markdown`'s meaning already names
});
+constexpr std::array kSummaryScopeValues = std::to_array({
+ {.value = "all", .meaning = "combine all input roots"},
+ {.value = "root", .meaning = "separate input roots"},
+ {.value = "left-only", .meaning = "comparison entries present only on the left"},
+ {.value = "right-only", .meaning = "comparison entries present only on the right"},
+ {.value = "different", .meaning = "different comparison pairs, with each side separate"},
+ {.value = "identical", .meaning = "identical comparison pairs, with each side separate"},
+ {.value = "left", .meaning = "alias for `left-only`"},
+ {.value = "right", .meaning = "alias for `right-only`"},
+ {.value = "diff", .meaning = "`left-only,right-only,different`"},
+ {.value = "compare", .meaning = "`left-only,right-only,different,identical`"},
+});
+
constexpr std::array kSummaryValues = std::to_array({
{.value = "none", .meaning = "clear all previously requested summaries"},
{.value = "compare", .meaning = "comparison counts and percentages by status; requires `--compare`"},
@@ -1153,7 +1166,8 @@ constexpr std::array kGlobals = std::to_array({
"`--path-encoding=escape` makes control bytes in the relative path unambiguous. "
"`--summary` / `--summary=compare` append comparison counts and percentages by status and "
"a total. "
- "Other summary groupings still describe each input tree separately.",
+ "Other summary groupings combine the input trees; `--summary-scope` separates roots or comparison "
+ "categories.",
.values = kCompareValues,
.topic = "compare",
.value_check = GlobalFlag::ValueCheck::kEnum,
@@ -1348,6 +1362,26 @@ constexpr std::array kGlobals = std::to_array({
.topic = "archive",
.extra = "archive",
},
+ {
+ .name = "--summary-scope",
+ .display = "--summary-scope=SCOPE,...",
+ .group = "stats",
+ .header = "Statistics",
+ .summary = "summarize all roots together, each root, or selected comparison categories",
+ .details = "Selects independent labelled tables for each ordinary `--summary` grouping. "
+ "`all` combines every root (the default); `root` separates input roots. "
+ "Comparison categories require `--compare`: `left` aliases `left-only`, `right` aliases "
+ "`right-only`, `diff` expands to `left-only,right-only,different`, and `compare` expands to "
+ "`left-only,right-only,different,identical`. Aliases expand in order and duplicate scopes "
+ "are removed. Repeating the flag replaces the preceding list. Selection is independent "
+ "of `--compare-select`. Paired categories preserve separate left and right statistics; "
+ "`all` counts both copies. This flag does not enable summaries or change `--summary=compare`.",
+ .values = kSummaryScopeValues,
+ .affects = "--summary",
+ .topic = "stats",
+ .see_also = "compare",
+ .value_check = GlobalFlag::ValueCheck::kEnumList,
+ },
{
.name = "--summary",
.display = "--summary[=]",
@@ -1356,7 +1390,9 @@ constexpr std::array kGlobals = std::to_array({
.summary = "aligned count + size table (or --format=jsonl rows) instead of each match; repeatable",
.details = "With `--compare`, bare `--summary` or `--summary=compare` appends result counts and "
"percentages by "
- "status and a total after the comparison output. Other groupings summarize each input tree. "
+ "status and a total after the comparison output. With explicit `--summary-scope`, bare `--summary` "
+ "also adds ordinary count and size statistics. Other groupings combine all input roots by default. "
+ "`--summary-scope` selects combined, per-root, or comparison-category tables. "
"Outside comparison, replaces the per-match listing with an aggregate table: match count and total "
"size per group "
"(overall, by type, extension, programming language, media (MIME) type, user (owner), owning "
diff --git a/xff/cli/help_build.cc b/xff/cli/help_build.cc
index 7d48e7d0f1..6af339425b 100644
--- a/xff/cli/help_build.cc
+++ b/xff/cli/help_build.cc
@@ -1033,7 +1033,8 @@ Section CompareSection(bool in_full) {
"including zero counts for empty comparisons. Each paired path counts once; directory-only entries are omitted "
"as in the status listing. Percentages use all compared results; `--summary-precision` controls decimals. "
"`--format=jsonl` renders these summary rows as `group`, `count`, and numeric `percent` objects. "
- "Other summary groupings describe each input tree separately."));
+ "Other summary groupings combine the input trees. `--summary-scope=root` separates roots; "
+ "`--summary-scope=compare` separates all comparison categories, preserving both sides of paired results."));
statuses.children.push_back(ProseOf(
"`--compare=summary` is shorthand for `--compare=status --compare-select=none --summary=compare` "
"at that position in the option sequence. Later selections can enable per-path output, and "
@@ -1062,6 +1063,7 @@ Section CompareSection(bool in_full) {
Bullets links;
links.items.push_back(ParseInline("`--summary=compare`: counts and percentages for all comparison results."));
links.items.push_back(ParseInline("`--compare-select`: choose per-path records; `none` suppresses the listing."));
+ links.items.push_back(ParseInline("`--summary-scope`: combined, per-root, or category statistics."));
links.items.push_back(ParseInline("`--summary-precision`: decimal places in summary percentages."));
links.items.push_back(ParseInline("`--format=jsonl`: machine-readable comparison summary rows."));
related.children.push_back(Content{.node = std::move(links)});
@@ -1069,6 +1071,9 @@ Section CompareSection(bool in_full) {
Subsection examples{.title = "Examples", .anchor = "topic-cookbook"};
examples.children.push_back(ExampleOf("xff --compare=summary left-tree right-tree", "sh"));
+ examples.children.push_back(
+ ExampleOf("xff --compare=summary left-tree right-tree --summary=ext --summary-scope=compare", "sh"));
+ examples.children.push_back(ProseOf("summarize extensions within each comparison category"));
examples.children.push_back(ProseOf("show only counts and percentages, with no per-path records"));
examples.children.push_back(ExampleOf("xff --compare left-tree right-tree", "sh"));
examples.children.push_back(ProseOf("print only paths present on one side or different on both sides"));
diff --git a/xff/cli/summary_test.sh b/xff/cli/summary_test.sh
index 26fce63754..c72d692e70 100755
--- a/xff/cli/summary_test.sh
+++ b/xff/cli/summary_test.sh
@@ -356,4 +356,72 @@ test::compare_summary_requires_compare_mode() {
expect_output_contains 'requires --compare' "${out}"
}
+test::summary_scopes_multiple_roots() {
+ local root out
+ root="$(_new_tree)"
+ mkdir "${root}/one" "${root}/two" "${root}/three"
+ printf abc >"${root}/one/a.txt"
+ printf de >"${root}/two/b.txt"
+ printf f >"${root}/three/c.cc"
+ out="$(_run "${root}/one" "${root}/two" "${root}/three" -type f --summary --format=jsonl)"
+ expect_eq '{"group":"total","count":3,"bytes":6}' "${out}"
+ out="$(_run "${root}/one" "${root}/two" "${root}/three" -type f --summary --summary-scope=root --format=jsonl)"
+ expect_output_contains "\"root\":\"${root}/one\",\"group\":\"total\",\"count\":1,\"bytes\":3" "${out}"
+ expect_output_contains "\"root\":\"${root}/two\",\"group\":\"total\",\"count\":1,\"bytes\":2" "${out}"
+ expect_output_contains "\"root\":\"${root}/three\",\"group\":\"total\",\"count\":1,\"bytes\":1" "${out}"
+}
+
+test::summary_scopes_comparison_aliases() {
+ local root out explicit
+ root="$(_new_tree)"
+ mkdir "${root}/left" "${root}/right"
+ printf abc >"${root}/left/only.txt"
+ printf defg >"${root}/right/extra.cc"
+ printf same >"${root}/left/equal.txt"
+ printf same >"${root}/right/equal.txt"
+ printf x >"${root}/left/change.txt"
+ printf yz >"${root}/right/change.txt"
+ out="$(_run --compare=summary "${root}/left" "${root}/right" -type f --summary=overall --summary-scope=compare --format=jsonl)"
+ expect_output_contains "\"scope\":\"left-only\",\"root\":\"${root}/left\",\"group\":\"total\",\"count\":1,\"bytes\":3" "${out}"
+ expect_output_contains "\"scope\":\"different\",\"root\":\"${root}/left\",\"group\":\"total\",\"count\":1,\"bytes\":1" "${out}"
+ expect_output_contains "\"scope\":\"different\",\"root\":\"${root}/right\",\"group\":\"total\",\"count\":1,\"bytes\":2" "${out}"
+ explicit="$(_run --compare=summary "${root}/left" "${root}/right" -type f --summary=overall --summary-scope=left,right,diff,identical,compare --format=jsonl)"
+ expect_eq "${out}" "${explicit}"
+ out="$(_run --compare=summary "${root}/left" "${root}/right" -type f --summary=overall --summary-scope=all --format=jsonl)"
+ expect_output_contains '"scope":"all","root":"","group":"total","count":6,"bytes":18' "${out}"
+}
+
+test::summary_scope_rejects_invalid_context() {
+ local root out rc
+ root="$(_new_tree)"
+ out="$("$(_xff_bin)" "${root}" --summary --summary-scope=diff 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ expect_output_contains 'require --compare' "${out}"
+ out="$("$(_xff_bin)" "${root}" --summary --summary-scope=garbage 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+}
+
+test::summary_scope_empty_root_and_replacement() {
+ local root out
+ root="$(_new_tree)"
+ out="$(_run "${root}" -type f --summary --summary-scope=root --format=jsonl)"
+ expect_eq "{\"scope\":\"root\",\"root\":\"${root}\",\"group\":\"total\",\"count\":0}" "${out}"
+ printf abc >"${root}/file.txt"
+ out="$(_run "${root}" -type f --summary --summary-scope=root --summary-scope=all --format=jsonl)"
+ expect_eq '{"scope":"all","root":"","group":"total","count":1,"bytes":3}' "${out}"
+ out="$(_run "${root}" -type f --summary-scope=root --format=jsonl)"
+ expect_not_matches '"scope"' "${out}"
+}
+
+test::summary_scope_template_and_collection() {
+ local root out
+ root="$(_new_tree)"
+ mkdir "${root}/one" "${root}/two"
+ printf abc >"${root}/one/a.txt"
+ printf de >"${root}/two/b.txt"
+ out="$(_run "${root}/one" "${root}/two" -type f -collect:files --summary='{ext}' --summary-scope=root --format=jsonl)"
+ expect_output_contains "\"root\":\"${root}/one\",\"group\":\"txt\",\"count\":1,\"bytes\":3" "${out}"
+ expect_output_contains "\"root\":\"${root}/two\",\"group\":\"txt\",\"count\":1,\"bytes\":2" "${out}"
+}
+
test_runner
diff --git a/xff/engine/run.cc b/xff/engine/run.cc
index 1704484428..5d80c840cd 100644
--- a/xff/engine/run.cc
+++ b/xff/engine/run.cc
@@ -329,6 +329,10 @@ std::vector ResolveSummaries(const std::vector& global
for (const std::string& global : globals) {
if (global == "--compare=summary" || (global == "--summary" && compare)) {
specs.push_back({.mode = SummaryMode::kCompare});
+ if (global == "--summary"
+ && absl::c_any_of(globals, [](std::string_view flag) { return flag.starts_with("--summary-scope="); })) {
+ specs.push_back({.mode = SummaryMode::kOverall});
+ }
} else if (global == "--summary=none") {
specs.clear();
} else if (global.starts_with("--summary={")) {
@@ -340,6 +344,49 @@ std::vector ResolveSummaries(const std::vector& global
return specs;
}
+// Scope selection is independent of per-path comparison output. Each occurrence replaces
+// the preceding list; aliases expand in place and duplicate scopes keep their first position.
+absl::StatusOr> ResolveSummaryScopes(const std::vector& globals, bool compare) {
+ std::vector scopes{"all"};
+ for (const std::string& global : globals) {
+ constexpr std::string_view kPrefix = "--summary-scope=";
+ if (!global.starts_with(kPrefix)) {
+ continue;
+ }
+ scopes.clear();
+ const auto append = [&](std::string_view scope) {
+ if (!absl::c_linear_search(scopes, scope)) {
+ scopes.emplace_back(scope);
+ }
+ };
+ for (std::string_view scope : absl::StrSplit(std::string_view(global).substr(kPrefix.size()), ',')) {
+ if (scope == "left") {
+ scope = "left-only";
+ } else if (scope == "right") {
+ scope = "right-only";
+ }
+ if (scope == "diff" || scope == "compare") {
+ append("left-only");
+ append("right-only");
+ append("different");
+ if (scope == "compare") {
+ append("identical");
+ }
+ } else if (
+ scope == "all" || scope == "root" || scope == "left-only" || scope == "right-only" || scope == "different"
+ || scope == "identical") {
+ append(scope);
+ } else {
+ return absl::InvalidArgumentError(absl::StrCat("unknown summary scope '", scope, "'"));
+ }
+ }
+ }
+ if (!compare && absl::c_any_of(scopes, [](std::string_view scope) { return scope != "all" && scope != "root"; })) {
+ return absl::InvalidArgumentError("comparison summary scopes require --compare");
+ }
+ return scopes;
+}
+
// The concatenation of every --summary={template} string, for the -capture reference check (a
// {capture.NAME} used in any summary key counts as referenced).
std::string AllSummaryTemplates(const std::vector& globals) {
@@ -2935,6 +2982,173 @@ int FlushArchiveDeletions(
// One accumulator per --summary sink: {group key -> {count, total size}}.
using SummaryCells = std::map>;
+using SummaryTables = std::vector;
+
+struct SummaryAccumulator {
+ std::size_t sinks = 0;
+ bool per_root = false;
+ bool per_entry = false;
+ std::map> partitions;
+
+ SummaryTables& For(const Visit& visit) {
+ auto& tables = partitions[per_root ? std::string(visit.root) : ""]
+ [per_entry ? std::string(RelativeTo(visit.path, visit.root)) : ""];
+ tables.resize(sinks);
+ return tables;
+ }
+};
+
+void MergeSummaryTables(SummaryTables& target, const SummaryTables& source) {
+ target.resize(std::max(target.size(), source.size()));
+ for (std::size_t i = 0; i < source.size(); ++i) {
+ for (const auto& [key, value] : source.at(i)) {
+ auto& cell = target.at(i)[key];
+ cell.first += value.first;
+ cell.second += value.second;
+ }
+ }
+}
+
+SummaryTables CombinedSummaryTables(const SummaryAccumulator& accumulator) {
+ SummaryTables result(accumulator.sinks);
+ for (const auto& [root, entries] : accumulator.partitions) {
+ for (const auto& [path, tables] : entries) {
+ MergeSummaryTables(result, tables);
+ }
+ }
+ return result;
+}
+
+struct SummaryRow {
+ std::string key;
+ std::uint64_t count = 0;
+ std::uint64_t size = 0;
+};
+
+std::vector SummaryRows(SummaryMode mode, const SummaryCells& cells, std::optional top) {
+ std::vector rows;
+ std::uint64_t total_count = 0;
+ std::uint64_t total_size = 0;
+ for (const auto& [key, value] : cells) {
+ rows.push_back({.key = key, .count = value.first, .size = value.second});
+ total_count += value.first;
+ total_size += value.second;
+ }
+ if (top.has_value() && mode != SummaryMode::kOverall) {
+ absl::c_sort(rows, [](const SummaryRow& left, const SummaryRow& right) {
+ if (left.size != right.size) {
+ return left.size > right.size;
+ }
+ if (left.count != right.count) {
+ return left.count > right.count;
+ }
+ return left.key < right.key;
+ });
+ if (rows.size() > *top) {
+ rows.resize(*top);
+ }
+ }
+ if (mode != SummaryMode::kOverall || rows.empty()) {
+ rows.push_back({.key = "total", .count = total_count, .size = total_size});
+ }
+ return rows;
+}
+
+void EmitSizedSummary(const std::vector& rows, format::SizeUnits human, unsigned precision, EmitFn emit) {
+ std::vector sizes;
+ sizes.reserve(rows.size());
+ std::size_t width = 0;
+ for (const auto& row : rows) {
+ auto parts = format::SizeColumns(row.size, human, precision);
+ width = std::max(width, parts.number.size());
+ sizes.push_back(std::move(parts));
+ }
+ format::Table table({format::Align::kLeft, format::Align::kRight, format::Align::kLeft});
+ for (std::size_t i = 0; i < rows.size(); ++i) {
+ table.AddRow(
+ {rows.at(i).key, format::Int(rows.at(i).count, ','),
+ absl::StrCat(format::PadLeft(sizes.at(i).number, width), " ", sizes.at(i).suffix)});
+ }
+ emit(table.Render());
+}
+
+void EmitSummaryRows(
+ const std::vector& rows,
+ render::Format output_format,
+ std::optional human,
+ unsigned precision,
+ EmitFn emit) {
+ const bool has_size = absl::c_any_of(rows, [](const SummaryRow& row) { return row.size > 0; });
+ if (output_format == render::Format::kJsonl) {
+ for (const auto& row : rows) {
+ std::string object = absl::StrCat("{\"group\":", JsonQuote(row.key), ",\"count\":", row.count);
+ if (has_size) {
+ absl::StrAppend(&object, ",\"bytes\":", row.size);
+ }
+ absl::StrAppend(&object, "}\n");
+ emit(object);
+ }
+ return;
+ }
+ if (has_size && human.has_value()) {
+ EmitSizedSummary(rows, *human, precision, emit);
+ return;
+ }
+ format::Table table(
+ has_size ? std::vector{format::Align::kLeft, format::Align::kRight, format::Align::kRight}
+ : std::vector{format::Align::kLeft, format::Align::kRight});
+ for (const auto& row : rows) {
+ std::vector cells{row.key, format::Int(row.count, ',')};
+ if (has_size) {
+ cells.push_back(format::Int(row.size, ','));
+ }
+ table.AddRow(std::move(cells));
+ }
+ emit(table.Render());
+}
+
+void EmitSummaries(
+ const std::vector& globals,
+ const std::vector& summaries,
+ const SummaryTables& tables,
+ render::Format output_format,
+ std::optional human,
+ EmitFn emit) {
+ const auto top = ResolveTop(globals);
+ const auto precision = ResolveSummaryPrecision(globals);
+ for (std::size_t i = 0; i < summaries.size(); ++i) {
+ if (i > 0 && output_format != render::Format::kJsonl) {
+ emit("\n");
+ }
+ EmitSummaryRows(SummaryRows(summaries.at(i).mode, tables.at(i), top), output_format, human, precision, emit);
+ }
+}
+
+void EmitScopedSummaries(
+ const std::vector& globals,
+ const std::vector& summaries,
+ const SummaryTables& tables,
+ render::Format format,
+ std::optional human,
+ std::string_view scope,
+ std::string_view root,
+ EmitFn emit) {
+ if (summaries.empty()) {
+ return;
+ }
+ if (format != render::Format::kJsonl) {
+ emit(absl::StrCat("Summary scope: ", scope, root.empty() ? "" : " (", root, root.empty() ? "" : ")", "\n"));
+ }
+ const auto labelled_emit = [&](std::string_view text) {
+ if (format == render::Format::kJsonl && text.starts_with("{")) {
+ emit(absl::StrCat("{\"scope\":", JsonQuote(scope), ",\"root\":", JsonQuote(root), ",", text.substr(1)));
+ } else {
+ emit(text);
+ }
+ };
+ EmitSummaries(globals, summaries, tables, format, human, labelled_emit);
+}
+
// Accumulates one matched unit into every --summary sink. A {template} key that is an m// EXTRACTION
// contributes a value stream (one count per extracted line, size not attributed - a per-line key
// would double-count the file's size); every other key contributes one entry, whose size is
@@ -2956,9 +3170,10 @@ struct CollectionRenderDefaults {
void FeedSummaries(
const std::vector& specs,
const std::vector>& templates,
- std::vector& cells_per_sink,
+ SummaryAccumulator& accumulator,
const fields::RenderContext& key_ctx,
const Visit& visit) {
+ auto& cells_per_sink = accumulator.For(visit);
for (std::size_t i = 0; i < specs.size(); ++i) {
if (specs[i].mode == SummaryMode::kHashVerification) {
continue; // fed from the -hasheq verdict, not from the expression's matched result
@@ -2993,12 +3208,13 @@ void FeedSummaries(
// reached -hasheq and therefore contributes nothing.
void FeedVerificationSummaries(
const std::vector& specs,
- std::vector& cells_per_sink,
+ SummaryAccumulator& accumulator,
const Visit& visit,
std::optional verification) {
if (!verification.has_value()) {
return;
}
+ auto& cells_per_sink = accumulator.For(visit);
for (std::size_t i = 0; i < specs.size(); ++i) {
if (specs[i].mode != SummaryMode::kHashVerification) {
continue;
@@ -3084,7 +3300,7 @@ void FeedCollections(
const CollectionRenderDefaults& defaults,
const std::vector& summaries,
const std::vector>& summary_templates,
- std::vector& summary_cells,
+ SummaryAccumulator& summary_cells,
const std::vector& histograms,
std::vector>& histogram_cells) {
for (const std::string_view name : collections.Names()) {
@@ -3122,7 +3338,7 @@ int FinishCollections(
WalkErrorFn on_error,
const std::vector& summaries,
const std::vector>& summary_templates,
- std::vector& summary_cells,
+ SummaryAccumulator& summary_cells,
const std::vector& histograms,
std::vector>& histogram_cells,
const CollectionRenderDefaults& defaults) {
@@ -3283,7 +3499,8 @@ RunResult RunFindCore(
WalkErrorFn on_error,
std::optional style,
mbo::types::OptionalRef matched_entry,
- bool compare_listing);
+ bool compare_listing,
+ mbo::types::OptionalRef comparison_summaries = std::nullopt);
struct TreeCompareCounts {
std::uint64_t left_only = 0;
@@ -3370,6 +3587,13 @@ RunResult RunTreeCompare(
}
}
+ const auto scopes_result = ResolveSummaryScopes(command.globals, true);
+ if (!scopes_result.ok()) {
+ on_error("--summary-scope", scopes_result.status());
+ return RunResult{.errors = 2};
+ }
+ std::array side_summaries;
+ std::map categories;
std::array entries;
std::mutex callback_mutex;
std::set reported_errors;
@@ -3399,7 +3623,7 @@ RunResult RunTreeCompare(
const WalkErrorFn synchronized_error = error_callback;
return RunFindCore(
command, absl::MakeConstSpan(command.roots).subspan(side, 1), fs, synchronized_emit, synchronized_error, style,
- collect, /*compare_listing=*/true);
+ collect, /*compare_listing=*/true, side_summaries.at(side));
};
std::future left_result = std::async(std::launch::async, run_side, 0);
const RunResult right_result = run_side(1);
@@ -3408,6 +3632,11 @@ RunResult RunTreeCompare(
return RunResult{.errors = std::max(left_run_result.errors, right_result.errors)};
}
+ const auto record_category = [&](const std::string& path, std::string_view category) {
+ if (side_summaries.at(0).per_entry || side_summaries.at(1).per_entry) {
+ categories.emplace(path, category);
+ }
+ };
bool different = false;
TreeCompareCounts counts;
const auto emit_status = [&](std::string_view status, std::string_view relative_path) {
@@ -3419,6 +3648,7 @@ RunResult RunTreeCompare(
if (right == entries[1].end() || (left != entries[0].end() && left->first < right->first)) {
if (left->second.metadata.type != vfs::FileType::kDirectory) {
++counts.left_only;
+ record_category(left->first, "left-only");
}
if (left->second.metadata.type != vfs::FileType::kDirectory && selection.left_only) {
if (output == TreeCompareOutput::kStatus) {
@@ -3438,6 +3668,7 @@ RunResult RunTreeCompare(
} else if (left == entries[0].end() || right->first < left->first) {
if (right->second.metadata.type != vfs::FileType::kDirectory) {
++counts.right_only;
+ record_category(right->first, "right-only");
}
if (right->second.metadata.type != vfs::FileType::kDirectory && selection.right_only) {
if (output == TreeCompareOutput::kStatus) {
@@ -3462,8 +3693,10 @@ RunResult RunTreeCompare(
}
if (*same && left->second.metadata.type != vfs::FileType::kDirectory) {
++counts.identical;
+ record_category(left->first, "identical");
} else if (!*same) {
++counts.different;
+ record_category(left->first, "different");
}
if (*same && left->second.metadata.type != vfs::FileType::kDirectory && selection.identical) {
emit_status("identical", left->first);
@@ -3486,6 +3719,33 @@ RunResult RunTreeCompare(
}
}
EmitTreeCompareSummary(command.globals, counts, emit);
+ auto summaries = ResolveSummaries(command.globals, true);
+ std::erase_if(summaries, [](const SummarySpec& spec) { return spec.mode == SummaryMode::kCompare; });
+ const auto format = ResolveFormat(command.globals);
+ const auto human = ResolveHuman(command.globals, style);
+ for (const std::string& scope : *scopes_result) {
+ if (scope == "all") {
+ auto tables = CombinedSummaryTables(side_summaries.at(0));
+ MergeSummaryTables(tables, CombinedSummaryTables(side_summaries.at(1)));
+ EmitScopedSummaries(command.globals, summaries, tables, format, human, scope, "", emit);
+ continue;
+ }
+ for (std::size_t side = 0; side < side_summaries.size(); ++side) {
+ if ((scope == "left-only" && side == 1) || (scope == "right-only" && side == 0)) {
+ continue;
+ }
+ SummaryTables tables(summaries.size());
+ for (const auto& [root, contributions] : side_summaries.at(side).partitions) {
+ for (const auto& [path, cells] : contributions) {
+ const auto category = categories.find(path.empty() ? "." : path);
+ if (scope == "root" || (category != categories.end() && category->second == scope)) {
+ MergeSummaryTables(tables, cells);
+ }
+ }
+ }
+ EmitScopedSummaries(command.globals, summaries, tables, format, human, scope, command.roots.at(side), emit);
+ }
+ }
return RunResult{.errors = 0, .any_match = different};
}
@@ -3503,7 +3763,8 @@ RunResult RunFindCore(
WalkErrorFn on_error,
std::optional style,
mbo::types::OptionalRef matched_entry,
- bool compare_listing) {
+ bool compare_listing,
+ mbo::types::OptionalRef comparison_summaries) {
bool any_match = false;
std::vector mime_vocabulary_files;
mime::ConflictPolicy mime_conflicts = mime::ConflictPolicy::kError;
@@ -3929,7 +4190,18 @@ RunResult RunFindCore(
const std::optional human =
ResolveHuman(command.globals, style); // --human: size units for --summary and -ls (xff -> human)
// One {group -> {count, total size}} accumulator per --summary sink.
- std::vector summary_cells(summaries.size());
+ const auto scopes_result = ResolveSummaryScopes(command.globals, compare_listing);
+ if (!scopes_result.ok()) {
+ on_error("--summary-scope", scopes_result.status());
+ return RunResult{.errors = 2};
+ }
+ const auto& scopes = *scopes_result;
+ SummaryAccumulator summary_cells{
+ .sinks = summaries.size(),
+ .per_root = compare_listing || absl::c_linear_search(scopes, "root"),
+ .per_entry = compare_listing
+ && absl::c_any_of(scopes, [](std::string_view scope) { return scope != "all" && scope != "root"; }),
+ };
// --histogram (repeatable): a bar chart of the count per bucket, alongside or instead of
// --summary. Both are reductions fed by one walk; a run with either suppresses the listing.
absl::StatusOr> histograms_or = ResolveHistograms(command.globals);
@@ -4930,104 +5202,29 @@ RunResult RunFindCore(
return RunResult{.errors = collect_status, .any_match = any_match};
}
- // --summary: emit one accumulated table per sink -- a row per group (the map is ordered) plus a
- // `total` row (the overall mode is already a single "total" group). Default is a right-aligned
- // human table (grouped digits); --format=jsonl emits one machine object per row. Multiple --summary
- // flags print in order, separated by a blank line. --top / --summary-precision are global and apply
- // to every table.
- if (!summaries.empty()) {
- const std::optional summary_top = ResolveTop(command.globals);
- const unsigned precision = ResolveSummaryPrecision(command.globals);
-
- struct Row {
- std::string key;
- std::uint64_t count = 0;
- std::uint64_t size = 0;
- };
-
- // The human size table: label left, grouped count, then the size as a right-aligned number and a
- // left-aligned unit (so decimal points line up). Extracted so emit_summary just dispatches.
- const auto emit_sized_table = [&](const std::vector& rows) {
- std::vector sizes;
- sizes.reserve(rows.size());
- std::size_t number_width = 0;
- for (const Row& row : rows) {
- format::SizeParts parts = format::SizeColumns(row.size, *human, precision);
- number_width = std::max(number_width, parts.number.size());
- sizes.push_back(std::move(parts));
- }
- format::Table table({format::Align::kLeft, format::Align::kRight, format::Align::kLeft});
- for (std::size_t i = 0; i < rows.size(); ++i) {
- table.AddRow(
- {rows[i].key, format::Int(rows[i].count, ','),
- absl::StrCat(format::PadLeft(sizes[i].number, number_width), " ", sizes[i].suffix)});
- }
- emit(table.Render());
- };
- const auto emit_summary = [&](SummaryMode mode,
- const std::map>& cells) {
- std::vector rows;
- std::uint64_t total_count = 0;
- std::uint64_t total_size = 0;
- for (const auto& [key, agg] : cells) {
- rows.push_back(Row{.key = key, .count = agg.first, .size = agg.second});
- total_count += agg.first;
- total_size += agg.second;
- }
- // Some summaries carry no size dimension (an m// extraction tallies keys, never bytes); a
- // total of zero means there is nothing size-worthy to show, so drop the size column / field
- // rather than print a spurious `0 B` (or a `bytes:0` jsonl field).
- const bool has_size = total_size > 0;
- // --top=N: keep the N largest groups by size (count, then key, break ties); the total row
- // still reflects every matched group. Absent => all groups in the map's alphabetical order.
- if (summary_top.has_value() && mode != SummaryMode::kOverall) {
- absl::c_sort(rows, [](const Row& lhs, const Row& rhs) {
- if (lhs.size != rhs.size) {
- return lhs.size > rhs.size;
- }
- if (lhs.count != rhs.count) {
- return lhs.count > rhs.count;
- }
- return lhs.key < rhs.key;
- });
- if (rows.size() > *summary_top) {
- rows.resize(*summary_top);
- }
- }
- if (mode != SummaryMode::kOverall) {
- rows.push_back(Row{.key = "total", .count = total_count, .size = total_size});
- }
- if (format == render::Format::kJsonl) {
- for (const Row& row : rows) {
- std::string obj = absl::StrCat("{\"group\":", JsonQuote(row.key), ",\"count\":", row.count);
- if (has_size) {
- absl::StrAppend(&obj, ",\"bytes\":", row.size);
- }
- absl::StrAppend(&obj, "}\n");
- emit(obj);
- }
- } else if (!has_size) {
- // Count-only: a two-column label / count table (no size dimension to report).
- format::Table table({format::Align::kLeft, format::Align::kRight});
- for (const Row& row : rows) {
- table.AddRow({row.key, format::Int(row.count, ',')});
+ if (comparison_summaries.has_value()) {
+ *comparison_summaries = std::move(summary_cells);
+ } else {
+ for (const std::string& scope : scopes) {
+ if (scope == "all") {
+ if (absl::c_any_of(
+ command.globals, [](std::string_view flag) { return flag.starts_with("--summary-scope="); })) {
+ EmitScopedSummaries(
+ command.globals, summaries, CombinedSummaryTables(summary_cells), format, human, scope, "", emit);
+ } else {
+ EmitSummaries(command.globals, summaries, CombinedSummaryTables(summary_cells), format, human, emit);
}
- emit(table.Render());
- } else if (human.has_value()) {
- emit_sized_table(rows);
} else {
- format::Table table({format::Align::kLeft, format::Align::kRight, format::Align::kRight});
- for (const Row& row : rows) {
- table.AddRow({row.key, format::Int(row.count, ','), format::Int(row.size, ',')});
+ for (const std::string& root : roots) {
+ SummaryTables tables(summaries.size());
+ if (const auto found = summary_cells.partitions.find(root); found != summary_cells.partitions.end()) {
+ for (const auto& [path, source] : found->second) {
+ MergeSummaryTables(tables, source);
+ }
+ }
+ EmitScopedSummaries(command.globals, summaries, tables, format, human, "root", root, emit);
}
- emit(table.Render());
- }
- };
- for (std::size_t i = 0; i < summaries.size(); ++i) {
- if (i > 0 && format != render::Format::kJsonl) {
- emit("\n"); // blank line between consecutive tables (jsonl streams objects, no separator)
}
- emit_summary(summaries[i].mode, summary_cells[i]);
}
}
From 8babaf120774c10a8d8d681adb899abe60612ee0 Mon Sep 17 00:00:00 2001
From: helly25 <6420169+helly25@users.noreply.github.com>
Date: Tue, 15 Sep 2026 21:30:10 +0100
Subject: [PATCH 2/4] Require an active file summary for summary scopes
---
CHANGELOG.md | 3 ++-
XFF.md | 2 +-
docs/design-summary-scopes.md | 6 +++++-
xff/cli/globals.cc | 4 +++-
xff/cli/summary_test.sh | 27 +++++++++++++++++++++++++--
xff/engine/run.cc | 26 +++++++++++++++++++++++---
6 files changed, 59 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 179c881a29..ece0ef6045 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,8 @@
# 0.6.0
- Add `--summary-scope` for combined, per-root, and comparison-category statistics, with
- ordered aliases and separate left/right statistics for paired categories.
+ ordered aliases and separate left/right statistics for paired categories. Scope selection
+ requires an active file summary; comparison-only summaries do not satisfy that requirement.
# 0.5.0
diff --git a/XFF.md b/XFF.md
index ae3d258eb8..7c23ada693 100644
--- a/XFF.md
+++ b/XFF.md
@@ -962,7 +962,7 @@ See also: [Configuration](#topic-config), [Archives](#topic-archive), [Output](#
- `diff` - `left-only,right-only,different`
- `compare` - `left-only,right-only,different,identical`
- Selects independent labelled tables for each ordinary `--summary` grouping. `all` combines every root (the default); `root` separates input roots. Comparison categories require `--compare`: `left` aliases `left-only`, `right` aliases `right-only`, `diff` expands to `left-only,right-only,different`, and `compare` expands to `left-only,right-only,different,identical`. Aliases expand in order and duplicate scopes are removed. Repeating the flag replaces the preceding list. Selection is independent of `--compare-select`. Paired categories preserve separate left and right statistics; `all` counts both copies. This flag does not enable summaries or change `--summary=compare`.
+ Selects independent labelled tables for each ordinary `--summary` grouping. `all` combines every root (the default); `root` separates input roots. Comparison categories require `--compare`: `left` aliases `left-only`, `right` aliases `right-only`, `diff` expands to `left-only,right-only,different`, and `compare` expands to `left-only,right-only,different,identical`. Aliases expand in order and duplicate scopes are removed. Repeating the flag replaces the preceding list. Selection is independent of `--compare-select`. Paired categories preserve separate left and right statistics; `all` counts both copies. Requires an active file summary such as `--summary` or `--summary=ext`; otherwise it is an error. `--compare=summary` expands to `--summary=compare`, which only counts comparison results and does not satisfy this requirement.
Affects: --summary
See also: [Statistics](#topic-stats), [Comparing trees](#topic-compare), [--summary](#flag-summary)
diff --git a/docs/design-summary-scopes.md b/docs/design-summary-scopes.md
index bafe908ca7..c6010ebc24 100644
--- a/docs/design-summary-scopes.md
+++ b/docs/design-summary-scopes.md
@@ -17,7 +17,11 @@ metadata remain attributable. Comparison counts count pairs once, while ordinary
statistics count both entries. Category tables use the same classification as comparison output,
including its directory exclusions. Output selection (`--compare-select`) does not filter statistics.
-`--summary-scope` never enables a summary. In comparison mode, bare `--summary` still requests
+`--summary-scope` requires an active file summary; absence is a usage error, including when
+`--summary=none` clears earlier summaries. `--compare=summary` expands to
+`--compare=status --compare-select=none --summary=compare`. Its comparison-only summary
+does not satisfy the requirement: add `--summary` or a grouping such as `--summary=ext`.
+In comparison mode, bare `--summary` still requests
comparison counts; with an explicit scope it also requests ordinary count and size statistics.
`--summary=compare` itself remains a whole-comparison count and percentage table.
diff --git a/xff/cli/globals.cc b/xff/cli/globals.cc
index 5849bb8125..d8306fb654 100644
--- a/xff/cli/globals.cc
+++ b/xff/cli/globals.cc
@@ -1375,7 +1375,9 @@ constexpr std::array kGlobals = std::to_array({
"`left-only,right-only,different,identical`. Aliases expand in order and duplicate scopes "
"are removed. Repeating the flag replaces the preceding list. Selection is independent "
"of `--compare-select`. Paired categories preserve separate left and right statistics; "
- "`all` counts both copies. This flag does not enable summaries or change `--summary=compare`.",
+ "`all` counts both copies. Requires an active file summary such as `--summary` or `--summary=ext`; "
+ "otherwise it is an error. `--compare=summary` expands to `--summary=compare`, which only counts "
+ "comparison results and does not satisfy this requirement.",
.values = kSummaryScopeValues,
.affects = "--summary",
.topic = "stats",
diff --git a/xff/cli/summary_test.sh b/xff/cli/summary_test.sh
index c72d692e70..ff47bc2155 100755
--- a/xff/cli/summary_test.sh
+++ b/xff/cli/summary_test.sh
@@ -409,8 +409,6 @@ test::summary_scope_empty_root_and_replacement() {
printf abc >"${root}/file.txt"
out="$(_run "${root}" -type f --summary --summary-scope=root --summary-scope=all --format=jsonl)"
expect_eq '{"scope":"all","root":"","group":"total","count":1,"bytes":3}' "${out}"
- out="$(_run "${root}" -type f --summary-scope=root --format=jsonl)"
- expect_not_matches '"scope"' "${out}"
}
test::summary_scope_template_and_collection() {
@@ -424,4 +422,29 @@ test::summary_scope_template_and_collection() {
expect_output_contains "\"root\":\"${root}/two\",\"group\":\"txt\",\"count\":1,\"bytes\":2" "${out}"
}
+test::summary_scope_requires_active_file_summary() {
+ local root out rc scope
+ root="$(_new_tree)"
+ mkdir "${root}/left" "${root}/right"
+ printf abc >"${root}/left/file.txt"
+ for scope in all left,right; do
+ out="$("$(_xff_bin)" --compare=summary "${root}/left" "${root}/right" "--summary-scope=${scope}" 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ expect_output_contains 'requires an active file summary' "${out}"
+ expect_output_contains '--compare=summary expands to --summary=compare' "${out}"
+ out="$(_run --compare=summary "${root}/left" "${root}/right" "--summary-scope=${scope}" --summary --format=jsonl)"
+ expect_output_contains '"scope":' "${out}"
+ done
+ out="$("$(_xff_bin)" "${root}" --summary-scope=root -exec echo SHOULD_NOT_RUN \; 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ expect_output_contains 'requires an active file summary' "${out}"
+ expect_not_matches 'SHOULD_NOT_RUN' "${out}"
+ out="$("$(_xff_bin)" --compare "${root}/left" "${root}/right" --summary=compare --summary-scope=all 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ out="$("$(_xff_bin)" "${root}" --summary --summary=none --summary-scope=root 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ out="$(_run "${root}" -type f --summary=none --summary-scope=root --summary=ext --format=jsonl)"
+ expect_output_contains '"scope":"root"' "${out}"
+}
+
test_runner
diff --git a/xff/engine/run.cc b/xff/engine/run.cc
index 5d80c840cd..0a63101203 100644
--- a/xff/engine/run.cc
+++ b/xff/engine/run.cc
@@ -344,6 +344,21 @@ std::vector ResolveSummaries(const std::vector& global
return specs;
}
+absl::Status ValidateSummaryScopeDriver(const std::vector& globals, bool compare) {
+ const bool explicit_scope =
+ absl::c_any_of(globals, [](std::string_view flag) { return flag.starts_with("--summary-scope="); });
+ if (!explicit_scope) {
+ return absl::OkStatus();
+ }
+ const auto summaries = ResolveSummaries(globals, compare);
+ if (absl::c_any_of(summaries, [](const SummarySpec& summary) { return summary.mode != SummaryMode::kCompare; })) {
+ return absl::OkStatus();
+ }
+ return absl::InvalidArgumentError(
+ "requires an active file summary such as --summary or --summary=ext; "
+ "--compare=summary expands to --summary=compare, which only summarizes comparison results");
+}
+
// Scope selection is independent of per-path comparison output. Each occurrence replaces
// the preceding list; aliases expand in place and duplicate scopes keep their first position.
absl::StatusOr> ResolveSummaryScopes(const std::vector& globals, bool compare) {
@@ -5328,9 +5343,14 @@ RunResult RunFind(
EmitFn emit,
WalkErrorFn on_error,
std::optional style) {
- if (absl::c_any_of(command.globals, [](std::string_view global) {
- return global == "--compare" || global.starts_with("--compare=");
- })) {
+ const bool compare = absl::c_any_of(command.globals, [](std::string_view global) {
+ return global == "--compare" || global.starts_with("--compare=");
+ });
+ if (const absl::Status status = ValidateSummaryScopeDriver(command.globals, compare); !status.ok()) {
+ on_error("--summary-scope", status);
+ return RunResult{.errors = 2};
+ }
+ if (compare) {
return RunTreeCompare(command, fs, emit, on_error, style);
}
return RunFindCore(
From de47c183ca7bb61e879864f35998627449cc1ade Mon Sep 17 00:00:00 2001
From: helly25 <6420169+helly25@users.noreply.github.com>
Date: Tue, 15 Sep 2026 21:57:10 +0100
Subject: [PATCH 3/4] Cover summary scope labels, sorting ties, and invalid
lists
---
xff/cli/summary_test.sh | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/xff/cli/summary_test.sh b/xff/cli/summary_test.sh
index ff47bc2155..4e7fc2e66d 100755
--- a/xff/cli/summary_test.sh
+++ b/xff/cli/summary_test.sh
@@ -447,4 +447,32 @@ test::summary_scope_requires_active_file_summary() {
expect_output_contains '"scope":"root"' "${out}"
}
+test::summary_scope_plain_labels_and_top_ties() {
+ local root out
+ root="$(_new_tree)"
+ mkdir "${root}/one" "${root}/two"
+ touch "${root}/one/a.txt" "${root}/one/b.txt" "${root}/one/c.cc" "${root}/one/d.h"
+ printf abc >"${root}/two/data.bin"
+ out="$(_run "${root}/one" -type f --summary=ext --summary-scope=root --top=2 --human=off)"
+ expect_output_contains "Summary scope: root (${root}/one)" "${out}"
+ expect_matches "txt +2${NL}cc +1${NL}total +4" "${out}"
+ expect_not_matches "(^|${NL})h +1" "${out}"
+ out="$(_run "${root}/one" "${root}/two" -type f --summary --summary-scope=all --human=off)"
+ expect_output_contains 'Summary scope: all' "${out}"
+ expect_matches "total +5 +3" "${out}"
+ out="$(_run --compare=summary "${root}/one" "${root}/two" -type f --summary=overall --summary-scope=left,right --human=off)"
+ expect_output_contains "Summary scope: left-only (${root}/one)" "${out}"
+ expect_output_contains "Summary scope: right-only (${root}/two)" "${out}"
+ expect_matches "total +1 +3" "${out}"
+}
+
+test::summary_scope_empty_list_is_an_error() {
+ local root out rc
+ root="$(_new_tree)"
+ mkdir "${root}/left" "${root}/right"
+ out="$("$(_xff_bin)" --compare=summary "${root}/left" "${root}/right" --summary=overall --summary-scope= 2>&1)" && rc=0 || rc=$?
+ expect_eq 2 "${rc}"
+ expect_output_contains "unknown summary scope ''" "${out}"
+}
+
test_runner
From 8aa6addaf420a573f57b30e52d7aa21626284a04 Mon Sep 17 00:00:00 2001
From: helly25 <6420169+helly25@users.noreply.github.com>
Date: Tue, 15 Sep 2026 21:59:57 +0100
Subject: [PATCH 4/4] Exercise uncovered summary branches in engine tests
---
xff/engine/run_test.cc | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/xff/engine/run_test.cc b/xff/engine/run_test.cc
index bc6c1c885d..b60e9d0969 100644
--- a/xff/engine/run_test.cc
+++ b/xff/engine/run_test.cc
@@ -1900,6 +1900,33 @@ TEST_F(RunTest, SummaryTopKeepsTheLargestGroupsBySize) {
ElementsAre(R"({"group":"txt","count":2,"bytes":2})", R"({"group":"total","count":3,"bytes":3})"));
}
+TEST_F(RunTest, SummaryScopesRenderPlainLabels) {
+ EXPECT_THAT(
+ RunArgvRecords({"--summary=ext", "--summary-scope=root", root_.string(), "-type", "f"}),
+ Contains(HasSubstr("Summary scope: root (" + root_.string() + ")")));
+ EXPECT_THAT(
+ RunArgvRecords({"--summary", "--summary-scope=all", root_.string(), "-type", "f"}),
+ Contains("Summary scope: all"));
+ EXPECT_THAT(last_errors_, 0);
+}
+
+TEST_F(RunTest, SummaryTopBreaksEqualSizeTiesByCountThenName) {
+ EXPECT_THAT(fs_.WriteContent(Path("a.txt"), ""), IsOk());
+ EXPECT_THAT(fs_.WriteContent(Path("b.md"), ""), IsOk());
+ EXPECT_THAT(fs_.WriteContent(Path("sub/c.txt"), ""), IsOk());
+ EXPECT_THAT(fs_.WriteContent(Path("d.h"), ""), IsOk());
+ EXPECT_THAT(
+ RunArgvRecords({"--summary=ext", "--top=2", "--format=jsonl", root_.string(), "-type", "f"}),
+ ElementsAre(R"({"group":"txt","count":2})", R"({"group":"h","count":1})", R"({"group":"total","count":4})"));
+}
+
+TEST_F(RunTest, ComparisonSummaryRejectsEmptyScopeList) {
+ EXPECT_THAT(
+ RunArgvRecords({"--compare=summary", root_.string(), Path("sub"), "--summary=overall", "--summary-scope="}),
+ IsEmpty());
+ EXPECT_THAT(last_errors_, 2);
+}
+
TEST_F(RunTest, SummaryOmitsSizeWhenNothingSizeWorthyIsAggregated) {
// Empty files -> the summary has no size dimension, so it reports counts only: no spurious
// `0 B` column in the human table and no `bytes:0` field in the jsonl rows (#156).