Comparing Kconfig Between Commits
The kcompare tool reports how the Kconfig configuration options of ESP-IDF change between two commits (or tags/branches). It lists configuration options and choices that were added, removed, or renamed, and config options/choices whose default values changed.
Overview
kcompare answers questions such as:
Which config options and choices appeared or disappeared between
v6.0andv6.1?Which options were renamed (and is the
sdkconfig.renamerecord present)?Which options changed their default value, and how?
Did a change reach all targets, or only some of them?
The comparison is always older → newer, so the direction of added / removed stays meaningful. kcompare decides which commit is older by trying, in order:
Git ancestry — if one commit is an ancestor of the other, it is the older one.
ESP-IDF version — if the commits have diverged (neither is an ancestor of the other), the
ESP_IDF_VERSION_MAJOR/MINOR/PATCHmacros fromesp_idf_version.hdecide it. This covers release/backport branches, for examplerelease/v6.0, which can carry commits made afterv6.1-beta1was tagged without being functionally newer.Argument order — if neither of the above can decide,
kcompareassumescommit1is older andcommit2is newer, and prints a warning so the choice is not silent.
How It Works
For each of the two commits, kcompare:
Creates a temporary git worktree for the commit under
.kcompare/worktrees/.Sets up the ESP-IDF environment for that worktree using eim (the ESP-IDF Installation Manager) and captures the environment needed to run the configuration engine.
Collects the root
Kconfig, allsdkconfig.renamefiles, and the component source-file lists, mimicking how ESP-IDF prepares inputs forkconfgen. This corresponds to a virtual project that includes all components.Runs the configuration engine (
kconfgen) and creates a configuration snapshot — only the data needed for the comparison (option and choice names, default values, rename records).
The two snapshots are then compared and the result is rendered in the requested format.
Note
eim must be installed and on PATH. If it is missing, kcompare stops with a hint on how to install it.
Prerequisites and Installation
kcomparemust be invoked from an ESP-IDF repository root (the directory containing the top-levelKconfigfile).The
esp-idf-kconfigpackage must be installed (it ships with ESP-IDF, or can be installed standalone withpip install esp-idf-kconfig).eim must be available for environment setup (see the note above).
Usage
python -m kcompare <commit1> <commit2> [output_file] [options]
Positional arguments:
commit1,commit2— the two commits or tags to compare. Their order usually does not matter;kcomparedetermines which one is older via git ancestry, then ESP-IDF version numbers, and only falls back to treatingcommit1as older when neither can decide (see Overview above).output_file(optional) — destination path for the report. When omitted (or set to-), the report is written to standard output.
Options:
-t, --target <chip>— the chip target to build the configuration for (for exampleesp32,esp32s3). When omitted,kcomparecompares every target common to both commits (discovered viaidf.py --list-targets) and combines the results.-o, --output <format>— output format:human(default, colorized console),markdown, orjson.--aggregation <mode>— how to group the changes:change(default),target, oraggregated. See Aggregation Modes for details.--by-codeowner— split the report by codeowner group, using.gitlab/CODEOWNERSfrom the newer commit from ESP-IDF repository. See Splitting the Report by Codeowner for details.--skip-install— skip theeim installstep (still captures the environment viaeim run); useful when the ESP-IDF toolchains and Python dependencies are already set up.--keep-worktrees— do not delete the temporary git worktrees under.kcompare/worktrees/(for later inspection or re-runs).-v, --verbose— show raweim/idf.pyoutput and Kconfig parser notes, which are suppressed by default.
Note
Progress and diagnostic messages are written to stderr. The report itself is written to the output file or to stdout, so capturing stdout always yields a clean report (see Using kcompare in CI).
Example usage — compare two commits for a single target and print a Markdown report:
python -m kcompare v6.0 v6.1 -t esp32 -o markdown
Example usage — compare across all common targets and write JSON to a file:
python -m kcompare v6.0 v6.1 report.json -o json
What Gets Compared
kcompare reports the following categories:
Added — config options (and choices) present in the newer commit but not in the older one.
Removed — config options (and choices) present in the older commit but not in the newer one.
Renamed — a config option
A_OLDin the older commit andA_NEWin the newer commit, where a matching record exists in ansdkconfig.renamefile in the newer commit.Defaults changed — config options (and choices) whose list of
defaultdefinitions differs.
Renames are only reported when a sdkconfig.rename record is present in the newer commit. The concept of “renaming” is otherwise ambiguous — prompt, defaults, or location may also change — so without a rename record kcompare cannot tell a rename from an unrelated remove + add. A missing rename record therefore shows up as the old name under Removed and the new name under Added, which is a useful signal that the record is missing.
Default values are compared lexically: kcompare compares the ordered list of default definitions as strings, including any if condition. As a consequence, a change to only the order of defaults, or a condition that references a renamed symbol (default !A_OLD vs. default !A_NEW), is reported as a change in default values.
Aggregation Modes
When comparing more than one target, the same change often appears for several targets. The --aggregation option controls how the results are grouped. All three modes contain the same information; they differ only in how it is organized.
change(default)One flat report where each individual change carries a
targets_affectedfield listing the targets it applies to. This is the most convenient view for “what changed, and where?” questions.targetDe-facto standalone diffs, one section per target. This is the single-target report repeated for each target. Useful when debugging a specific chip (for example, “something stopped working between
v6.0andv6.1onesp32p4; what changed there?”). Less convenient when you care about the change itself rather than a specific chip.aggregatedThe same structure as
target, but each entry is keyed by a set of targets instead of a single target. Identical changes are merged, removing duplication. The group whose target set equals every compared target is the “all” group; its contents are the changes that were propagated to every chip. Inspecting the “all” group together with the per-subset groups answers questions like “was this change propagated to all and only the intended targets?”.
JSON Output and Schema
Use -o json for machine-readable output. Every JSON document has a stable preamble followed by a reports field whose shape depends on the aggregation mode.
Preamble (present in all modes)
aggregation(str) —"change","target", or"aggregated".older_commit/newer_commit(str) — the commit refs as given on the command line.older_sha/newer_sha(str) — the resolved full commit SHAs.targets_compared(list of str) — targets actually compared (present in both commits).targets_skipped(list of object) — targets present in only one commit and therefore not compared. Each item hastarget,present_in(the commit ref where it exists), andmissing_from(the commit ref where it is absent).
Important
The reports field is an object in change mode (the default), but a list in target and aggregated modes. Branch on aggregation (or on the requested mode) before parsing reports.
The change body
All modes reuse the same set of category buckets:
added/removed— options by name.renamed— objects withold_name,new_name, andinverted(truewhen the rename also inverts the value).defaults_changed— objects withid(symbol name, or choice id — a name or afile:linelocation for unnamed choices),kind("symbol"or"choice"), the orderedold_defaults/new_defaultsstring lists,old_default_symbols/new_default_symbols(the default target symbol names, for choices), andold_options/new_options(the choice’s member option names; empty for symbols).choices_added/choices_removed— objects withidandoptions(the choice’s member config names).
How each mode presents these buckets:
change—reportsis a single body object; every item is an object annotated with atargets_affectedlist. Because the annotation lives on the item,added/removedare lists of objects (for example{"name": "FOO", "targets_affected": ["esp32"]}) rather than plain strings.target—reportsis a list; each entry is one target’s body plus atargetfield.added/removedare plain string lists.aggregated—reportsis a list; each entry is a body plus atargetslist. The entry covering every compared target is the “all” group; when 2+ targets were compared itstargetsis folded to the["all"]sentinel.added/removedare plain string lists.
Note
When running kcompare for at least two targets and given change affects all targets, instead of verbosely listing all targets, the targets_affected field is folded to the single value ["all"] for readability. In the Markdown output the same situation is rendered as all chips.
Example — change mode (default)
{
"aggregation": "change",
"older_commit": "v6.0",
"older_sha": "aaaaaaaaaaaa...",
"newer_commit": "v6.1",
"newer_sha": "bbbbbbbbbbbb...",
"targets_compared": ["esp32", "esp32s2"],
"targets_skipped": [
{"target": "esp32c3", "present_in": "v6.1", "missing_from": "v6.0"}
],
"reports": {
"added": [
{"name": "COMMON", "targets_affected": ["all"]},
{"name": "ESP32_ONLY", "targets_affected": ["esp32"]}
],
"removed": [],
"renamed": [
{"old_name": "A_OLD", "new_name": "A_NEW", "inverted": false,
"targets_affected": ["all"]}
],
"defaults_changed": [
{"id": "FOO", "kind": "symbol",
"old_defaults": ["y"], "new_defaults": ["n"],
"old_default_symbols": [], "new_default_symbols": [],
"old_options": [], "new_options": [],
"targets_affected": ["all"]}
],
"choices_added": [
{"id": "MYCHOICE", "options": ["CHOICE_A", "CHOICE_B"],
"targets_affected": ["esp32"]}
],
"choices_removed": []
}
}
Example — target mode
{
"aggregation": "target",
"older_commit": "v6.0",
"older_sha": "aaaaaaaaaaaa...",
"newer_commit": "v6.1",
"newer_sha": "bbbbbbbbbbbb...",
"targets_compared": ["esp32", "esp32s2"],
"targets_skipped": [
{"target": "esp32c3", "present_in": "v6.1", "missing_from": "v6.0"}
],
"reports": [
{
"target": "esp32",
"added": ["COMMON", "ESP32_ONLY"],
"removed": [],
"renamed": [{"old_name": "A_OLD", "new_name": "A_NEW", "inverted": false}],
"defaults_changed": [
{"id": "FOO", "kind": "symbol",
"old_defaults": ["y"], "new_defaults": ["n"],
"old_default_symbols": [], "new_default_symbols": [],
"old_options": [], "new_options": []}
],
"choices_added": [
{"id": "MYCHOICE", "options": ["CHOICE_A", "CHOICE_B"]}
],
"choices_removed": []
},
{
"target": "esp32s2",
"added": ["COMMON"],
"removed": [],
"renamed": [{"old_name": "A_OLD", "new_name": "A_NEW", "inverted": false}],
"defaults_changed": [
{"id": "FOO", "kind": "symbol",
"old_defaults": ["y"], "new_defaults": ["n"],
"old_default_symbols": [], "new_default_symbols": [],
"old_options": [], "new_options": []}
],
"choices_added": [],
"choices_removed": []
}
]
}
Example — aggregated mode
{
"aggregation": "aggregated",
"older_commit": "v6.0",
"older_sha": "aaaaaaaaaaaa...",
"newer_commit": "v6.1",
"newer_sha": "bbbbbbbbbbbb...",
"targets_compared": ["esp32", "esp32s2"],
"targets_skipped": [
{"target": "esp32c3", "present_in": "v6.1", "missing_from": "v6.0"}
],
"reports": [
{
"targets": ["all"],
"added": ["COMMON"],
"removed": [],
"renamed": [{"old_name": "A_OLD", "new_name": "A_NEW", "inverted": false}],
"defaults_changed": [
{"id": "FOO", "kind": "symbol",
"old_defaults": ["y"], "new_defaults": ["n"],
"old_default_symbols": [], "new_default_symbols": [],
"old_options": [], "new_options": []}
],
"choices_added": [],
"choices_removed": []
},
{
"targets": ["esp32"],
"added": ["ESP32_ONLY"],
"removed": [],
"renamed": [],
"defaults_changed": [],
"choices_added": [
{"id": "MYCHOICE", "options": ["CHOICE_A", "CHOICE_B"]}
],
"choices_removed": []
}
]
}
Splitting the Report by Codeowner
With --by-codeowner, kcompare splits a single comparison into one report per codeowner group, so each team sees only the changes in the components it owns. This is convenient for routing release-note or regression review to the right people.
Ownership is resolved from the .gitlab/CODEOWNERS file of the newer commit. Only the /components rules are used: the path after /components identifies the component (a directory, possibly with a * wildcard) and the groups are the names after @esp-idf-codeowners/ (nested names such as app-utilities/console are kept). When several rules match a path, the last matching rule wins, mirroring GitLab’s own behavior.
Older tags (for example v6.0 and v6.1-beta1) often ship a CODEOWNERS file trimmed for easier backports: a catch-all * rule and fewer than ten /components entries.
In that case kcompare warns and walks the last three commits that modified .gitlab/CODEOWNERS on the newer side, using the newest revision that still has at least ten /components rules (typically the pre-trim parent).
If none qualifies, it falls back to master:.gitlab/CODEOWNERS from the repository being compared.
Each changed option or choice is attributed to a group based on where it is defined (the Kconfig file path of its definition):
A change defined in a component maps to that component’s groups.
A symbol or choice defined in several components (multiple definitions) is attributed to the union of all those components’ groups.
A change whose definition path does not match any
/componentsrule (for example a symbol defined in the rootKconfig) is attributed to every group, so that no owner misses it.
Only groups that end up with at least one change are emitted.
--by-codeowner combines with --aggregation: the report is first built in the requested aggregation mode and then split, so each group’s report keeps the same shape (change, target, or aggregated).
Output depends on whether an output_file is given:
No output file (stdout). For
humanandmarkdown, the group reports are printed one after another, each preceded by aReport for codeowner group <name>header. Forjson, a single object is emitted with the usual preamble plus acodeowner_groupsfield mapping each group name to its report, so the output stays valid JSON.With an output file. The path is treated as a template:
report.jsonbecomesreport_<group>.json(for examplereport_system.json), with/in nested group names replaced by_(app-utilities/consolebecomesapp-utilities_console). Each file is a normal single-group report and its preamble carries acodeowner_groupfield.
Note
--by-codeowner requires a usable CODEOWNERS source: the newer checkout’s file (at least ten /components rules), else a recent pre-trim revision of that file, else master:.gitlab/CODEOWNERS.
If the newer file is missing, or none of those sources clears the threshold, kcompare stops with an error.
The master ref must be available in the local repository for the last-resort fallback to work.
Example — split a Markdown report to standard output:
python -m kcompare v6.0 v6.1 -o markdown --by-codeowner
Example — split JSON into one file per group:
python -m kcompare v6.0 v6.1 report.json -o json --by-codeowner
Using kcompare in CI
kcompare is designed to run unattended in CI. Recommendations:
Capture standard output as JSON. Run with
-o jsonand either write to anoutput_fileor read standard output. Progress and log messages go to standard error, so standard output is always a clean JSON document.Pick the aggregation for your check.
changeis the default and is usually the most convenient for automated rules (“fail if any option was removed”, “warn on default changes”), because each change lists the targets it affects. Useaggregatedto assert propagation across targets, ortargetfor a per-chip report.Watch
targets_skipped. A non-empty list means a target exists on only one side of the comparison; depending on your policy this may warrant a warning.Set up the environment once. If your job already has the ESP-IDF checkout prepared with
eim, pass--skip-installto save time.
Minimal example that fails the job when any option is removed on any target:
python -m kcompare "$BASE" "$HEAD" report.json -o json --aggregation change
import json, sys
data = json.load(open("report.json"))
removed = data["reports"]["removed"] # 'change' mode: reports is an object
if removed:
for item in removed:
print(f"removed: {item['name']} on {', '.join(item['targets_affected'])}", file=sys.stderr)
sys.exit(1)