diff --git a/dotbot/cli/_fw_sources.py b/dotbot/cli/_fw_sources.py index 485be125..afa976ad 100644 --- a/dotbot/cli/_fw_sources.py +++ b/dotbot/cli/_fw_sources.py @@ -431,6 +431,7 @@ def write_build_manifest( for f in files: entries[f.name] = { "sha256": sha256(f), + "repo": str(Path(repo).resolve()), "git_sha": sha, "dirty": dirty, "build_config": config, @@ -453,6 +454,18 @@ def write_build_manifest( return manifest +def recorded_repo(out_dir: Path) -> Path | None: + """The source folder a built set's manifest names, or None.""" + try: + manifest = json.loads((out_dir / MANIFEST_NAME).read_text()) + except (OSError, ValueError): + return None + if not isinstance(manifest, dict) or manifest.get("kind") != "build": + return None + repo = manifest.get("repo") + return Path(repo) if repo else None + + def collect(files: Iterable[Path], out_dir: Path) -> list[Path]: """Copy `files` flat into `out_dir` under their own names. diff --git a/dotbot/cli/fw.py b/dotbot/cli/fw.py index 4dfe4e2e..372d02ba 100644 --- a/dotbot/cli/fw.py +++ b/dotbot/cli/fw.py @@ -222,6 +222,15 @@ def _source_repo(source: str, path: Path | None, explicit: bool) -> Path: default=False, help="Print where each artifact would be collected, without building.", ) +@click.option( + "--force", + is_flag=True, + default=False, + help=( + "Build into an --as set that was built from another source folder, " + "which then names this one. Its other images stay as they were built." + ), +) @_verbose_option @click.pass_context def build( @@ -236,6 +245,7 @@ def build( set_name, rebuild, print_path, + force, verbose, ): """Build firmware from your source folders into the cache. @@ -329,6 +339,19 @@ def build( planned.append((source, src_repo, cfg, target, files, df_apps)) root = artifacts_dir() + for source, src_repo, *_ in planned: + out = fs.set_dir(source, set_name, root) + recorded = fs.recorded_repo(out) + if force or recorded is None or recorded == src_repo.resolve(): + continue + raise click.ClickException( + f"The '{set_name}' {source} set was built from {recorded}, not " + f"{src_repo.resolve()}: images from two source folders in one set " + "can disagree, e.g. on the swarmit NSC table.\n" + f" - build from the set's folder: --path {recorded}\n" + " - or build into another set: --as \n" + " - or mix them anyway: --force" + ) if print_path: for source, _, _, _, files, _ in planned: out = fs.set_dir(source, set_name, root) @@ -340,8 +363,11 @@ def build( t0 = time.perf_counter() for source, src_repo, cfg, board, _, selection in planned: mode = "rebuild" if rebuild or source != APP_SOURCE else "incremental" + origin = "--path" if path is not None else "configured source" click.echo( - f"Building {_LABELS[source]} for {board} ({cfg}, {mode})...", err=True + f"Building {_LABELS[source]} for {board} ({cfg}, {mode}) from " + f"{src_repo} ({origin})...", + err=True, ) if source == "swarmit": files = fs.build_swarmit( diff --git a/dotbot/tests/test_fw_sources.py b/dotbot/tests/test_fw_sources.py index e2d5c324..ddc41130 100644 --- a/dotbot/tests/test_fw_sources.py +++ b/dotbot/tests/test_fw_sources.py @@ -958,3 +958,29 @@ def test_list_survives_a_malformed_manifest(isolated, manifest): assert result.exit_code == 0, result.output assert "swarmit-odd" in result.output assert " netcore-nrf5340-net.hex" in result.output + + +# --- the set's source folder ---------------------------------------------- + + +def test_build_into_a_set_from_another_folder_is_refused( + isolated, swarmit_repo, fake_build +): + other = isolated / "wt-swarmit" + shutil.copytree(swarmit_repo, other) + assert build("swarmit-sandbox", "--as", "bench").exit_code == 0 + result = build("swarmit-sandbox", "--path", str(other), "--as", "bench") + assert result.exit_code != 0 + assert str(swarmit_repo.resolve()) in result.output + assert str(other.resolve()) in result.output + assert "--force" in result.output + forced = build("swarmit-sandbox", "--path", str(other), "--as", "bench", "--force") + assert forced.exit_code == 0, forced.output + assert f"from {other}" in forced.output + + +def test_build_says_which_source_folder_it_builds_from( + isolated, firmware_repo, fake_make +): + result = build("dotbot") + assert f"from {firmware_repo} (configured source)" in result.output