From b2bcd9c90740c1cabbdf11786443860e9242eddd Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Thu, 17 Sep 2026 09:15:51 +0200 Subject: [PATCH 1/2] ci: cache FetchContent sources so a run clones once, not a dozen times Closes morph#552. Every configure clones `glaze`, `Catch2`, `Lightweight` and `doxygen-awesome-css` again. One CI run configures more than a dozen times, so a single push produces dozens of anonymous clones from the self-hosted fleet's shared egress address. GitHub answers a throttled anonymous clone with 401, git falls back to prompting for credentials, and with no TTY that surfaces as fatal: could not read Username for 'https://github.com': No such device or address which reads like an auth misconfiguration and is not one. Evidence the issue did not have. Within a single run on 2026-09-16, six self-hosted clones succeeded between 20:12 and 20:14, then every job starting 20:19-20:22 failed this way -- the same five runners and the same egress address. Not an outage: a threshold. The volume is the lever, so this reduces it rather than retrying into it. `FETCHCONTENT_SOURCE_DIR_` makes FetchContent use an existing tree and skip the download entirely. `cmake/DepCache.cmake` points every declaration at one cache directory, populating it on first use, which turns "a clone per configure" into "a clone per runner, once". Placed in CMake rather than in ci.yml on purpose. There are twelve configure sites in ci.yml and three more workflows that run cmake; the four `FetchContent_Declare` calls are one place. Editing twelve call sites to pass flags would be churn with twelve chances to miss one -- and a missed one is invisible, because it still builds. Deliberately not `FETCHCONTENT_FULLY_DISCONNECTED`: a cache miss falls back to cloning. An optimisation that can break a build is a liability, so the failure path is a STATUS message and an unset variable, not an error. Inert unless configured. `MORPH_DEP_CACHE` wins if set; otherwise CI gets a default under the runner's home, which persists across jobs on a self-hosted runner. A local build gets nothing: a developer's builds are not what exhausts a rate limit, and silently sharing source trees between their checkouts would be a surprising thing to do. scripts/test_dep_cache.sh asserts the four properties, because a cache that silently stops caching is indistinguishable from a working one -- the build still succeeds, it just clones again. It earned its keep immediately: the first version used `CMakeLists.txt` as its validity marker, which `doxygen-awesome-css` does not have, so that entry would have been re-cloned on every configure while looking exactly like a working cache. The marker is now an explicit sentinel written only after both the clone and the checkout succeed, which also distinguishes a complete entry from a tree left by an interrupted populate. Verified: cold configure populates once with zero FetchContent clones; a second configure from a different build directory reuses it with zero clones and zero populates; an unconfigured build produces no dep-cache activity at all; and an unreachable repository leaves the configure standing with the variable unset. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/drift-guard.yml | 15 ++++ CMakeLists.txt | 4 ++ cmake/DepCache.cmake | 116 ++++++++++++++++++++++++++++++ docs/CMakeLists.txt | 2 + examples/bank/CMakeLists.txt | 2 + examples/common/CMakeLists.txt | 2 + scripts/test_dep_cache.sh | 108 ++++++++++++++++++++++++++++ 7 files changed, 249 insertions(+) create mode 100644 cmake/DepCache.cmake create mode 100755 scripts/test_dep_cache.sh diff --git a/.github/workflows/drift-guard.yml b/.github/workflows/drift-guard.yml index c95f5930e..39b7bf5fd 100644 --- a/.github/workflows/drift-guard.yml +++ b/.github/workflows/drift-guard.yml @@ -62,6 +62,21 @@ jobs: - name: Assert UBSan halts instead of recovering run: bash scripts/check_sanitizer_can_fail.sh + dep-cache-selftest: + name: Dependency-cache self-test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + # A dependency cache that silently stops caching is indistinguishable + # from a working one -- the build still succeeds, it just clones again -- + # so the four properties cmake/DepCache.cmake promises are asserted + # rather than assumed (morph#552). The first version of the helper used + # `CMakeLists.txt` as its validity marker, which a stylesheet repository + # does not have; this self-test is what caught it. + - name: Self-test the FetchContent dependency cache + run: bash scripts/test_dep_cache.sh + prose-lint: name: Spec-citation, banned-terminology & CI-clang-pin lint runs-on: ubuntu-24.04 diff --git a/CMakeLists.txt b/CMakeLists.txt index b5a7410f3..681336823 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,6 +141,8 @@ set(MORPH_GLAZE_VERSION 7.4) find_package(glaze ${MORPH_GLAZE_VERSION} CONFIG QUIET) if(NOT glaze_FOUND) include(FetchContent) + include(cmake/DepCache.cmake) + morph_cache_dep(glaze https://github.com/stephenberry/glaze.git v7.4.0) FetchContent_Declare( glaze GIT_REPOSITORY https://github.com/stephenberry/glaze.git @@ -550,6 +552,8 @@ if(MORPH_BUILD_TESTS) find_package(Catch2 CONFIG QUIET) if(NOT Catch2_FOUND) include(FetchContent) + include(cmake/DepCache.cmake) + morph_cache_dep(Catch2 https://github.com/catchorg/Catch2.git v3.8.1) FetchContent_Declare( Catch2 GIT_REPOSITORY https://github.com/catchorg/Catch2.git diff --git a/cmake/DepCache.cmake b/cmake/DepCache.cmake new file mode 100644 index 000000000..c3b6b7c3f --- /dev/null +++ b/cmake/DepCache.cmake @@ -0,0 +1,116 @@ +# ── A shared source cache for FetchContent dependencies (morph#552) ────────── +# +# Every configure in CI clones `glaze`, `Catch2`, `Lightweight` and +# `doxygen-awesome-css` again from github.com. One run configures more than a +# dozen times, so a single push produces dozens of anonymous clones from the +# self-hosted fleet's shared egress address -- and GitHub answers a throttled +# anonymous clone with 401, which makes git fall back to prompting for +# credentials and, with no TTY, fail as: +# +# fatal: could not read Username for 'https://github.com': No such device or address +# +# which reads like an auth misconfiguration and is not one. Measured on +# 2026-09-16 within a single run: six self-hosted clones succeeded between +# 20:12 and 20:14, then every job starting 20:19-20:22 failed this way -- the +# same five runners and the same egress address, so not an outage but a +# threshold. +# +# `FETCHCONTENT_SOURCE_DIR_` makes FetchContent use an existing tree and +# skip the download entirely. Pointing every configure at one cache directory +# therefore turns "a clone per configure" into "a clone per runner, once", +# which is the volume that trips the limit. +# +# Deliberately *not* `FETCHCONTENT_FULLY_DISCONNECTED`: a cache miss must fall +# back to cloning rather than fail the build. The cache is an optimisation, and +# an optimisation that can break a build is a liability. + +# Where cached sources live. An explicit `MORPH_DEP_CACHE` wins; otherwise CI +# gets a default under the runner's home, which persists across jobs on a +# self-hosted runner. A local build gets nothing unless it opts in -- a +# developer's builds are not what exhausts a rate limit, and silently sharing +# sources between their checkouts would be a surprising thing to do. +if(DEFINED ENV{MORPH_DEP_CACHE}) + set(MORPH_DEP_CACHE_DIR "$ENV{MORPH_DEP_CACHE}") +elseif(DEFINED ENV{CI} AND DEFINED ENV{HOME}) + set(MORPH_DEP_CACHE_DIR "$ENV{HOME}/.cache/morph-dep-cache") +else() + set(MORPH_DEP_CACHE_DIR "") +endif() + +# Points FetchContent at a cached checkout of @p name, populating the cache on +# first use. A no-op when no cache directory is configured, or when the caller +# already set FETCHCONTENT_SOURCE_DIR_ explicitly. +# +# `tag` is part of the directory name, so bumping a pin lands in a fresh +# directory instead of silently reusing the old revision -- the failure mode a +# cache keyed on name alone would have, and the one that is hardest to notice +# because everything still builds. +function(morph_cache_dep name repository tag) + if(MORPH_DEP_CACHE_DIR STREQUAL "") + return() + endif() + find_package(Git QUIET) + if(NOT Git_FOUND) + return() # FetchContent needs git too; let it produce the diagnostic + endif() + string(TOUPPER "${name}" _upper) + if(DEFINED FETCHCONTENT_SOURCE_DIR_${_upper} AND NOT FETCHCONTENT_SOURCE_DIR_${_upper} STREQUAL "") + return() # an explicit override wins, including the one CI may pass + endif() + + string(SUBSTRING "${tag}" 0 16 _short_tag) + string(MAKE_C_IDENTIFIER "${name}-${_short_tag}" _slug) + set(_dir "${MORPH_DEP_CACHE_DIR}/${_slug}") + # An explicit sentinel, written only after the clone *and* the checkout + # succeeded, rather than probing for a file the dependency might not have. + # The first version of this used `CMakeLists.txt`, which is not present in + # every dependency -- `doxygen-awesome-css` is a stylesheet repository -- + # so that entry would have been re-cloned on every configure while looking + # exactly like a working cache. It also distinguishes a complete entry from + # a tree left behind by an interrupted populate. + set(_stamp "${_dir}/.morph-dep-cache-ok") + + if(NOT EXISTS "${_stamp}") + message(STATUS "morph: dep cache: populating ${name} (${tag}) at ${_dir}") + file(MAKE_DIRECTORY "${MORPH_DEP_CACHE_DIR}") + # Clone into a per-process staging path and rename into place, so two + # configures racing on the same runner cannot leave a half-written tree + # that later builds would treat as a valid cache entry. The rename is + # atomic within one filesystem; whichever loses the race just discards + # its own copy. + string(RANDOM LENGTH 12 _stage_id) + set(_staging "${_dir}.tmp.${_stage_id}") + file(REMOVE_RECURSE "${_staging}") + execute_process( + COMMAND ${GIT_EXECUTABLE} clone --quiet "${repository}" "${_staging}" + RESULT_VARIABLE _clone_result + ERROR_VARIABLE _clone_error) + if(NOT _clone_result EQUAL 0) + # Not fatal: FetchContent will do its own clone, exactly as before. + message(STATUS "morph: dep cache: could not pre-clone ${name} (${_clone_error}); " + "leaving it to FetchContent") + file(REMOVE_RECURSE "${_staging}") + return() + endif() + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${_staging}" checkout --quiet "${tag}" + RESULT_VARIABLE _checkout_result) + if(NOT _checkout_result EQUAL 0) + message(STATUS "morph: dep cache: ${tag} did not check out for ${name}; leaving it to FetchContent") + file(REMOVE_RECURSE "${_staging}") + return() + endif() + file(TOUCH "${_staging}/.morph-dep-cache-ok") + if(NOT EXISTS "${_stamp}") + file(REMOVE_RECURSE "${_dir}") + file(RENAME "${_staging}" "${_dir}" RESULT _rename_result) + endif() + file(REMOVE_RECURSE "${_staging}") + endif() + + if(EXISTS "${_stamp}") + set(FETCHCONTENT_SOURCE_DIR_${_upper} "${_dir}" CACHE PATH + "Cached ${name} source tree (morph#552)" FORCE) + message(STATUS "morph: dep cache: ${name} from ${_dir}") + endif() +endfunction() diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 01ecb3043..4f3ca1928 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -2,6 +2,8 @@ find_package(Doxygen REQUIRED) message(STATUS "Doxygen found: ${DOXYGEN_EXECUTABLE}") include(FetchContent) +include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) +morph_cache_dep(doxygen-awesome-css https://github.com/jothepro/doxygen-awesome-css.git v2.3.4) FetchContent_Declare( doxygen-awesome-css GIT_REPOSITORY https://github.com/jothepro/doxygen-awesome-css.git diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index 907e4832a..4a884d73d 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -38,6 +38,8 @@ set(LIGHTWEIGHT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) +include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) +morph_cache_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git bbb972a78e1962b968a2c6ad93f7dade736eaa01) FetchContent_Declare(Lightweight GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git # Kept in sync with examples/common/CMakeLists.txt's identical pin -- diff --git a/examples/common/CMakeLists.txt b/examples/common/CMakeLists.txt index 6e97b2282..2458cca91 100644 --- a/examples/common/CMakeLists.txt +++ b/examples/common/CMakeLists.txt @@ -130,6 +130,8 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) # the DLL export boundary (and its warnings) entirely instead of punching warning # holes through every consumer target. set(LIGHTWEIGHT_BUILD_SHARED OFF CACHE BOOL "" FORCE) +include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) +morph_cache_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git bbb972a78e1962b968a2c6ad93f7dade736eaa01) FetchContent_Declare(Lightweight GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git # Pinned to master's tip commit, not the latest tag (v0.20260625.0) -- diff --git a/scripts/test_dep_cache.sh b/scripts/test_dep_cache.sh new file mode 100755 index 000000000..c422bc1e5 --- /dev/null +++ b/scripts/test_dep_cache.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Usage: bash scripts/test_dep_cache.sh +# +# Self-test for cmake/DepCache.cmake (morph#552). A dependency cache that +# silently stops caching looks exactly like one that is working -- the build +# still succeeds, it just clones again -- so the four properties it promises are +# asserted rather than assumed: +# +# 1. Unconfigured, it is a complete no-op (a developer's local build is +# unchanged, and does not start sharing source trees between checkouts). +# 2. Cold, it populates the cache and FetchContent performs no clone. +# 3. Warm, it reuses the tree without re-populating, from a different build +# directory and a different build type. +# 4. When the pre-clone fails, the configure survives and leaves +# FETCHCONTENT_SOURCE_DIR_ unset, so FetchContent still gets its +# chance. The cache is an optimisation; an optimisation that can break a +# build is a liability. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failures=0 + +fail() { printf 'error: %s\n' "$*" >&2; failures=$((failures + 1)); } + +# A standalone project, so the assertions are about DepCache.cmake rather than +# about whatever else morph's own configure happens to do. +mkdir -p "${work}/proj" +cat > "${work}/proj/CMakeLists.txt" <<'CMAKE' +cmake_minimum_required(VERSION 3.25) +project(morph_dep_cache_selftest NONE) +include("${MORPH_DEPCACHE}") +morph_cache_dep("${DEP_NAME}" "${DEP_REPO}" "${DEP_TAG}") +string(TOUPPER "${DEP_NAME}" _upper) +if(DEFINED FETCHCONTENT_SOURCE_DIR_${_upper} AND NOT FETCHCONTENT_SOURCE_DIR_${_upper} STREQUAL "") + message(STATUS "selftest: pointed at ${FETCHCONTENT_SOURCE_DIR_${_upper}}") +else() + message(STATUS "selftest: not pointed anywhere") +endif() +CMAKE + +configure_once() { + local build="$1" cache="$2" name="$3" repo="$4" tag="$5" + rm -rf "${build}" + local env_args=() + if [ -n "${cache}" ]; then + env_args=(env "MORPH_DEP_CACHE=${cache}") + else + # Also clear CI, or the helper's CI default would kick in and this + # would no longer be the "unconfigured" case it claims to test. + env_args=(env -u MORPH_DEP_CACHE -u CI) + fi + "${env_args[@]}" cmake -S "${work}/proj" -B "${build}" \ + -DMORPH_DEPCACHE="${repo_root}/cmake/DepCache.cmake" \ + -DDEP_NAME="${name}" -DDEP_REPO="${repo}" -DDEP_TAG="${tag}" 2>&1 +} + +# ── 1. Unconfigured: a complete no-op ─────────────────────────────────────── +out="$(configure_once "${work}/b1" "" selftestdep https://example.invalid/x.git v1)" +if grep -q "dep cache" <<< "${out}"; then + fail "with no cache configured the helper still acted: it must be inert for local builds" +fi +if ! grep -q "selftest: not pointed anywhere" <<< "${out}"; then + fail "with no cache configured FETCHCONTENT_SOURCE_DIR was set anyway" +fi + +# ── 2/3. Cold populate, then warm reuse ───────────────────────────────────── +# A tiny public repository, so the self-test does not clone something large. +readonly probe_repo="https://github.com/jothepro/doxygen-awesome-css.git" +readonly probe_tag="v2.3.4" +cache="${work}/cache" +mkdir -p "${cache}" + +cold="$(configure_once "${work}/b2" "${cache}" doxygen-awesome-css "${probe_repo}" "${probe_tag}")" +if ! grep -q "dep cache: populating" <<< "${cold}"; then + fail "a cold cache did not populate" +fi +if ! grep -q "selftest: pointed at" <<< "${cold}"; then + fail "after populating, FETCHCONTENT_SOURCE_DIR was not set" +fi + +warm="$(configure_once "${work}/b3" "${cache}" doxygen-awesome-css "${probe_repo}" "${probe_tag}")" +if grep -q "dep cache: populating" <<< "${warm}"; then + fail "a warm cache populated again -- it is not being reused" +fi +if ! grep -q "selftest: pointed at" <<< "${warm}"; then + fail "a warm cache did not point FetchContent at the cached tree" +fi + +# ── 4. A failed pre-clone degrades instead of breaking ────────────────────── +if broken="$(configure_once "${work}/b4" "${cache}" nosuchdep \ + https://github.com/LASTRADA-Software/definitely-not-a-real-repo-552.git v1.0.0)"; then + if ! grep -q "could not pre-clone" <<< "${broken}"; then + fail "an unreachable repository did not report a failed pre-clone" + fi + if ! grep -q "selftest: not pointed anywhere" <<< "${broken}"; then + fail "after a failed pre-clone FETCHCONTENT_SOURCE_DIR was set to a tree that was never populated" + fi +else + fail "an unreachable repository aborted the configure -- the cache must never be able to break a build" +fi + +if [ "${failures}" -ne 0 ]; then + echo "dep-cache self-test: ${failures} assertion(s) failed" + exit 1 +fi +echo "dep-cache self-test: inert when unconfigured, populates cold, reuses warm, degrades on failure." From c0f0b7b669826a87f08ec88d63e418549315a3f8 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Thu, 17 Sep 2026 16:35:59 +0200 Subject: [PATCH 2/2] check_coverage_roots: admit the dependency cache, and prove it still catches a foreign worktree Moving the FetchContent sources out of `build/*/_deps` put them outside the checkout, and `check_coverage_roots.sh` fails on any coverage record that is not under it -- so the clang-coverage leg went red on 56 Lightweight headers. The gate was doing its job; its rule had simply been an over-approximation that happened to hold. Third-party dependency sources being outside the checkout is normal. They were only ever inside because FetchContent put them in the build directory, and coverage.sh drops them either way -- it filters to `include/morph` and the example rungs -- so their absence from the report is intended rather than the silence this gate exists to catch. What it is actually looking for is *morph's own* sources arriving from a foreign worktree through a path-independent compiler cache (morph#426), and a dependency cache is not a foreign worktree. The widening is deliberately narrow: the one cache directory, resolved exactly as DepCache.cmake resolves it so the two cannot drift apart about where it is, and nothing else. Every other foreign root still fails. That "and nothing else" is the part worth testing, so the self-test asserts both directions: a file in the configured cache passes, *and* a foreign worktree still fails while a cache is configured. Without the second, widening this gate would be indistinguishable from blinding it -- which is precisely the failure mode the gate was written to catch, turned on itself. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_coverage_roots.sh | 43 ++++++++++++++++++++++++++-- scripts/test_check_coverage_roots.sh | 35 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/scripts/check_coverage_roots.sh b/scripts/check_coverage_roots.sh index 8ade45a96..1b151dd40 100755 --- a/scripts/check_coverage_roots.sh +++ b/scripts/check_coverage_roots.sh @@ -78,6 +78,29 @@ readonly profdata="${build_dir}/merged.profdata" # symlink would report every file as foreign. readonly source_root="$(pwd -P)" +# Third-party dependency sources are legitimately outside the checkout +# (morph#552). `cmake/DepCache.cmake` points FetchContent at a shared cache so a +# CI run clones once instead of a dozen times, and those trees then sit under +# the runner's home rather than under `build/*/_deps`, where they used to be +# only because FetchContent happened to put them there. +# +# They are dropped from the report either way -- coverage.sh filters to +# `include/morph` and the example rungs -- so their absence is intended, not the +# silence this gate exists to catch. What it is looking for is *morph's own* +# sources arriving from a foreign worktree, and that hazard is untouched by +# this: a foreign worktree is not the dependency cache. +# +# Resolved exactly as DepCache.cmake resolves it, so the two cannot drift into +# disagreeing about where the cache is. +if [ -n "${MORPH_DEP_CACHE:-}" ]; then + dep_cache_root="${MORPH_DEP_CACHE}" +elif [ -n "${CI:-}" ] && [ -n "${HOME:-}" ]; then + dep_cache_root="${HOME}/.cache/morph-dep-cache" +else + dep_cache_root="" +fi +readonly dep_cache_root + if [ -n "$export_json_file" ]; then export_json="$(cat "$export_json_file")" else @@ -139,8 +162,17 @@ printf '%s' "$export_json" | python3 -c ' import json, os, sys source_root = os.path.realpath(sys.argv[1]) + os.sep +dep_cache_arg = sys.argv[2] if len(sys.argv) > 2 else "" +dep_cache_root = (os.path.realpath(dep_cache_arg) + os.sep) if dep_cache_arg else "" document = json.load(sys.stdin) +def is_allowed(name): + if name.startswith(source_root): + return True + # Narrow on purpose: this admits the dependency cache and nothing else, so + # a source from any other foreign root still fails. + return bool(dep_cache_root) and name.startswith(dep_cache_root) + filenames = [f["filename"] for export in document["data"] for f in export["files"]] if not filenames: print("check_coverage_roots: the coverage mapping names no files at all.", @@ -150,7 +182,7 @@ if not filenames: print(" to find, committed by the detector (morph#426).", file=sys.stderr) raise SystemExit(1) -foreign = sorted({f for f in filenames if not f.startswith(source_root)}) +foreign = sorted({f for f in filenames if not is_allowed(f)}) if foreign: print("check_coverage_roots: %d of %d files in the coverage mapping are not" % (len(foreign), len(filenames)), file=sys.stderr) @@ -167,5 +199,10 @@ if foreign: print(" -DUSE_COMPILER_CACHE=OFF, or delete the build tree and rebuild.", file=sys.stderr) raise SystemExit(1) -print("check_coverage_roots: %d files, all under the checkout." % len(filenames)) -' "$source_root" +cached = sum(1 for f in filenames if not f.startswith(source_root)) +if cached: + print("check_coverage_roots: %d files, %d under the checkout and %d in the dependency cache." + % (len(filenames), len(filenames) - cached, cached)) +else: + print("check_coverage_roots: %d files, all under the checkout." % len(filenames)) +' "$source_root" "$dep_cache_root" diff --git a/scripts/test_check_coverage_roots.sh b/scripts/test_check_coverage_roots.sh index 8975a520b..a5a93a3d8 100755 --- a/scripts/test_check_coverage_roots.sh +++ b/scripts/test_check_coverage_roots.sh @@ -20,6 +20,8 @@ # Asserts five directions: # # 1. every file under the checkout -> pass +# 1b. a file in the configured dependency cache -> pass (morph#552) +# 1c. a foreign worktree, cache also configured -> still fail # 2. one file under another worktree -> fail, naming it # 3. a sibling directory sharing the root's prefix -> fail # 4. a mapping naming no files at all -> fail, not a vacuous pass @@ -99,6 +101,39 @@ else fi fi +# 2b. The dependency cache is admitted (morph#552): its trees are third-party +# sources that coverage.sh filters out anyway, and they live outside the +# checkout only because DepCache.cmake shares them across a run's dozen +# configures instead of re-cloning each time. +write_export "$tmp/depcache.json" \ + "${repo_root}/include/morph/core/bridge.hpp" \ + "$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp" +if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" "$tmp/depcache.json" > "$tmp/2b.out" 2>&1; then + note "ok 2b: a file in the configured dependency cache passes" +else + fail "2b: a file in the dependency cache was rejected" + cat "$tmp/2b.out" >&2 +fi + +# 2c. The load-bearing half of 2b. Admitting the dependency cache must not +# admit *anything* outside the checkout, or this gate would have been widened +# into uselessness -- which is the failure mode it was written to catch, turned +# on itself. A foreign worktree must still fail while a cache is configured. +write_export "$tmp/depcache-and-foreign.json" \ + "${repo_root}/include/morph/core/bridge.hpp" \ + "$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp" \ + "/home/somebody/repo/morph-wt/999/examples/crm/src/models/account_model.cpp" +if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" \ + "$tmp/depcache-and-foreign.json" > "$tmp/2c.out" 2>&1; then + fail "2c: with a dependency cache configured, a foreign worktree was accepted too" + cat "$tmp/2c.out" >&2 +elif grep -q "morph-wt/999" "$tmp/2c.out"; then + note "ok 2c: a foreign worktree still fails while the dependency cache is configured" +else + fail "2c: rejected, but did not name the foreign path" + cat "$tmp/2c.out" >&2 +fi + # 3. A sibling whose path shares the checkout's prefix as a string. write_export "$tmp/sibling.json" \ "${repo_root}/include/morph/core/bridge.hpp" \