diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7f5f782..7456b9e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -53,7 +53,16 @@ Consequences: A panel-only change reruns the core plus the panel tests, not the cooling drills. - A platform change (kernel, device tree, a layer's content) invalidates - everything. + everything. Layer content is every file under `meta-forgefirm`, + `meta-glowforge-bsp` and `meta-openglow-core` except documentation + (`*.md`) and the component pin files (`-pin.inc`, holding only a + component's `SRCREV` and the `PV` that moves with it). A pin bump is the + component's change, and the component entry already carries it file by + file, so it invalidates the tests that cover the component - not the + bench. A recipe-body change (build flags, patches, config fragments, + init scripts, a third-party pin with no manifest entry) is layer content + and invalidates everything; so does a pin written into a recipe body + instead of its pin file (the safe direction). - A change to a test's implementation invalidates that test's earlier passes and no other. - "Touched" is computed from content hashes carried in the image, never diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index af082a0..a1f5d34 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -1592,9 +1592,43 @@ the probe's own writes and the controller's init writes once. **Images `20260816191951` (forgefirm-image-dev) are built on that tree - forgectrl `c8f6558`, the day's forgetest, acceptance identity `c72448c2…` equal on both - and archived under `images/20260816191838/` with checksums (the -previous pair, `215236`/`215332`, under `images/20260815215236/`). The -confirmation campaign runs on the flashed dev image; every domain -forgectrl does not touch inherits.** +previous pair, `215236`/`215332`, under `images/20260815215236/`).** + +**2026-08-16, dev image `20260816191951` flashed: every test came up +`domain-changed`, none inherited - the expectation above ("every domain +forgectrl does not touch inherits") was wrong, and the tool was right.** +The two dev-image manifests differ in exactly one platform field: +`platform.layers.meta-forgefirm.content_sha256` (`b2d13d87…` → +`7ef5555d…`); machine, kernel modules and DTB hashes are equal, and the +only components that moved are forgectrl (7 files) and the dev-only +forgetest. The only non-`.md` change in `meta-forgefirm` between the two +builds is the one-line SRCREV bump in `forgectrl.bb` (`1d9b553`) - the +layer is content-hashed into the platform identity, the platform is +folded into every fingerprint, so the pin bump counted as a platform +change and invalidated the whole catalog. Structural, not a fluke: every +component update that ships in an image rides a pin bump in a +content-hashed layer, so under that rule every image with any component +change was an invalidate-all and the per-domain inheritance the contract +promises could never hold across images (the component entry already +carries the change file by file; the pin double-counted it). Fixed the +same day: component pins live in `-pin.inc` (SRCREV + the PV that +moves with it, nothing else) and `forgefirm-image-manifest.bbclass` leaves +`*-pin.inc` out of the layer content (`FORGEFIRM_MANIFEST_PIN_SUFFIX`), +mirrored in `scripts/manifest-from-tree.py` and proven by +`forgetest/tests/test_tree_manifest.py` (pin bump → hash unchanged; recipe +body change or a pin written into the recipe → hash changed, the safe +direction); the six component recipes (forgectrl, grblhal-glowforge, +forgefirm-app in meta-forgefirm; kernel-module-glowforge, +python3-gfhardware, python3-gfutilities in meta-openglow) require their +pin files and resolve the same SRCREV/PV under bitbake. A second, smaller +contributor stays as designed: a test's implementation hash is its suite +module, so the day's edits to `suite/{cloud,cooling,forgectrl,kernel, +motion}.py` alone would have re-required 16 of the 26. **Consequence for +the bench: the fix changes the layer content itself, so the first image +built with it is a platform change against everything recorded so far - +that image's campaign is a full one, unavoidably; from then on a +component pin bump re-requires only the tests covering that component. +Run the full campaign on the first pin-file image, not on `191951`.** ## Hardware facts bank (measured) @@ -2924,12 +2958,15 @@ forgectrl does not touch inherits.** core, FAIL/ERROR closing a campaign, implementation and component changes invalidating exactly their domains) behaved as specified across the day's closures; the baseline rule was added on the way - (record in "Release acceptance" above). Remaining: (a) the - confirmation campaign on the **flashed dev image `20260816191951`** - (built; `images/20260816191838/`; it carries the day's tool fixes and - the forgectrl changes; every domain forgectrl does not touch - inherits) - the tool on the bench is the tree, but a hot-patched image - is not the image that ships; (b) the remaining + (record in "Release acceptance" above). The flash of `20260816191951` + exposed the layer-hash over-invalidation (a component pin bump counted + as a platform change; fixed - pins in `-pin.inc`, left out of + the layer content; record in "Release acceptance" above). Remaining: + (a) the confirmation campaign - a **full** one, on the **first image + built with the pin files** (that build is a platform change against + every result so far; after it, a component pin bump re-requires only + the tests covering that component) - the tool on the bench is the + tree, but a hot-patched image is not the image that ships; (b) the remaining bench-tab ports (scope tools, host-side flow characterization, the live drills - the catalog carries their acceptance forms); (c) the first release runs the full campaign and commits diff --git a/forgetest/tests/test_tree_manifest.py b/forgetest/tests/test_tree_manifest.py new file mode 100644 index 0000000..f205b8e --- /dev/null +++ b/forgetest/tests/test_tree_manifest.py @@ -0,0 +1,107 @@ +"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image +manifest: recipe pins are read through their pin files, and a layer's +content hash leaves the pin files out (a component bump is not a platform +change; a recipe-body change is).""" +import importlib.util +import os +import shutil +import subprocess +import tempfile +import unittest + +import helpers # noqa: F401 (sys.path) + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SCRIPT = os.path.join(REPO, "scripts", "manifest-from-tree.py") + + +def load_script(): + spec = importlib.util.spec_from_file_location("manifest_from_tree", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def git_ok(): + try: + subprocess.run(["git", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + return True + except (OSError, subprocess.CalledProcessError): + return False + + +RECIPE = '''DESCRIPTION = "component" +LICENSE = "MIT" +SRC_URI = "git://github.com/example/comp.git;protocol=https;branch=main" +# SRCREV and PV live in the pin file. +require comp-pin.inc +S = "${WORKDIR}/git" +''' +PIN_A = 'SRCREV = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\nPV = "1.0"\n' +PIN_B = 'SRCREV = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nPV = "1.1"\n' + + +@unittest.skipUnless(git_ok(), "git not available") +class TreeManifestTests(unittest.TestCase): + def setUp(self): + self.mod = load_script() + self.tmp = tempfile.mkdtemp(prefix="forgetest-tree-") + self.layer = os.path.join(self.tmp, "meta-x") + self.rdir = os.path.join(self.layer, "recipes-x", "comp") + os.makedirs(self.rdir) + subprocess.run(["git", "init", "-q"], cwd=self.tmp, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.write("comp.bb", RECIPE) + self.write("comp-pin.inc", PIN_A) + self.write("README.md", "# docs\n") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def write(self, name, text): + with open(os.path.join(self.rdir, name), "w", encoding="utf-8", newline="\n") as f: + f.write(text) + + def test_parse_recipe_follows_the_pin_file(self): + url, rev = self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb")) + self.assertEqual(url, "https://github.com/example/comp.git") + self.assertEqual(rev, "a" * 40) + self.write("comp-pin.inc", PIN_B) + self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "b" * 40) + + def test_parse_recipe_inline_pin_still_works(self): + self.write("comp.bb", RECIPE.replace("require comp-pin.inc\n", PIN_A)) + os.remove(os.path.join(self.rdir, "comp-pin.inc")) + self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "a" * 40) + + def test_layer_hash_ignores_pin_bumps_and_docs(self): + base = self.mod.layer_content(self.layer) + self.assertIsNotNone(base) + self.write("comp-pin.inc", PIN_B) + self.assertEqual(self.mod.layer_content(self.layer), base, "a pin bump is not layer content") + self.write("README.md", "# docs, revised\n") + self.assertEqual(self.mod.layer_content(self.layer), base, "documentation is not layer content") + + def test_layer_hash_sees_recipe_body_changes(self): + base = self.mod.layer_content(self.layer) + self.write("comp.bb", RECIPE + 'EXTRA_OEMAKE += "KCFLAGS=-Werror"\n') + self.assertNotEqual(self.mod.layer_content(self.layer), base) + + def test_layer_hash_sees_a_pin_written_into_the_recipe(self): + # The safe direction: a pin that bypasses its pin file still hashes. + base = self.mod.layer_content(self.layer) + self.write("comp.bb", RECIPE + PIN_B) + self.assertNotEqual(self.mod.layer_content(self.layer), base) + + def test_layer_hash_matches_the_bbclass_rule(self): + # The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in + # forgefirm-image-manifest.bbclass. + bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass") + with open(bbclass, encoding="utf-8") as f: + text = f.read() + self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text) + self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES, (".md", "-pin.inc")) + + +if __name__ == "__main__": + unittest.main() diff --git a/kas/README.md b/kas/README.md index 0c2b7f0..3a816d7 100644 --- a/kas/README.md +++ b/kas/README.md @@ -93,9 +93,15 @@ config move in the right order. The sequence, with current status: (`kernel-module-glowforge`, `python3-gfhardware`, `Glowforge-Utilities`, `grblHAL-glowforge`, `forgectrl`) is on GitHub and its recipe pins an exact `SRCREV` — no `AUTOREV` anywhere. Whenever a source repo changes: push it, - then bump the recipe `SRCREV` deliberately (BSP recipes in meta-openglow, - ForgeFIRM components in meta-forgefirm) and re-verify with - `bitbake -c fetch `. + then bump the pin deliberately (BSP recipes in meta-openglow, ForgeFIRM + components in meta-forgefirm) and re-verify with + `bitbake -c fetch `. A component's `SRCREV` (and the `PV` that + moves with it) lives in `-pin.inc` next to the recipe, nothing + else goes in that file: the image manifest leaves `*-pin.inc` out of the + layer content hash, so a pin bump changes the component's fingerprint + and only that (`docs/ACCEPTANCE.md`) — a pin written into the recipe body + still builds, but counts as a platform change and forces a full + acceptance campaign. 2. **meta-openglow pushed** — **DONE.** The Scarthgap port lives on the **`scarthgap` branch** (Yocto layer convention; the Dunfell-era `master` is untouched). Development continues on the local sibling checkout; push / diff --git a/meta-forgefirm/classes/forgefirm-image-manifest.bbclass b/meta-forgefirm/classes/forgefirm-image-manifest.bbclass index 511405c..54763cc 100644 --- a/meta-forgefirm/classes/forgefirm-image-manifest.bbclass +++ b/meta-forgefirm/classes/forgefirm-image-manifest.bbclass @@ -22,6 +22,19 @@ # host-specific (paths, user, time) is recorded: the manifest travels in # a public release artifact. # +# Two kinds of file are left out of a layer's content: documentation +# (*.md) and component pin files (*FORGEFIRM_MANIFEST_PIN_SUFFIX, +# "-pin.inc"). A pin file holds nothing but the SRCREV (and the PV +# that rides with it) of a component that has its own manifest entry; the +# entry already identifies that source file by file, so hashing the pin as +# layer content would turn every component update into a platform change +# and invalidate every acceptance result instead of the tests that cover +# the component. Everything else a recipe carries - build flags, patches, +# config fragments, init scripts, the pins of third-party sources that +# have no manifest entry - is layer content and stays in the hash. A pin +# written into a recipe body instead of its pin file still hashes: the +# safe direction (a full campaign, not a missed one). +# # content_sha256 is the identity of the build's inputs: sha256 over the # canonical JSON (sorted keys, no whitespace) of {"components", "platform"}. # The image name, version string and the build section are metadata @@ -30,19 +43,22 @@ FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d" FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core" +FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc" do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy" ROOTFS_POSTPROCESS_COMMAND += "forgefirm_manifest_assemble;" forgefirm_manifest_assemble[vardepsexclude] += "DATETIME" -def forgefirm_manifest_layer_content(path): +def forgefirm_manifest_layer_content(path, skip_suffixes=('.md',)): + """sha256 over (path, git blob id) of every file under the layer + directory except those whose name ends in one of skip_suffixes.""" import hashlib, os, subprocess out = subprocess.run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'], cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout paths = sorted(set(p.decode('utf-8', 'replace') for p in out.split(b'\0') if p)) paths = [p for p in paths - if os.path.isfile(os.path.join(path, p)) and not p.endswith('.md')] + if os.path.isfile(os.path.join(path, p)) and not p.endswith(tuple(skip_suffixes))] if not paths: return None # hash-object --stdin-paths resolves against the repository top level @@ -68,6 +84,7 @@ def forgefirm_manifest_layers(d): and dirty flag of every layer checkout (informational).""" import os, subprocess content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split() + skip = ('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split()) identity, build = {}, {} for layer in (d.getVar('BBLAYERS') or '').split(): name = os.path.basename(layer.rstrip('/')) @@ -83,7 +100,7 @@ def forgefirm_manifest_layers(d): if name in content_layers: if rev is None: bb.fatal("forgefirm-image-manifest: layer %s must be a git checkout to be content-hashed" % name) - identity[name] = {'content_sha256': forgefirm_manifest_layer_content(layer)} + identity[name] = {'content_sha256': forgefirm_manifest_layer_content(layer, skip)} else: identity[name] = {'rev': rev} return identity, build diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc new file mode 100644 index 0000000..276a646 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -0,0 +1,6 @@ +# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep +# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the +# layer content hash because the component entry already identifies the +# pinned source (forgefirm-image-manifest.bbclass). +SRCREV = "c8f655813ba2b9a44fdf032e84435d9454909d7c" +PV = "0.1.0" diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb index 20ab533..7530e56 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb @@ -5,11 +5,10 @@ LICENSE = "MIT" LIC_FILES_CHKSUM = "file://LICENSE;md5=19ed4e3e8c28a4311c16b0b2b91357ec" PE = "1" -PV = "0.1.0" SRC_URI = "git://github.com/ScottW514/forgectrl.git;protocol=https;branch=main" -# Pinned; bump deliberately after pushing forgectrl changes. -SRCREV = "c8f655813ba2b9a44fdf032e84435d9454909d7c" +# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass). +require forgectrl-pin.inc S = "${WORKDIR}/git" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc new file mode 100644 index 0000000..1ce7818 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc @@ -0,0 +1,12 @@ +# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree). +# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV +# here - the image manifest leaves *-pin.inc out of the layer content hash +# because the component entry already identifies the pinned source +# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in +# meta-glowforge-bsp pins the same repository; move both together. +SRCREV = "a0a174d8e9a6b4b6e3abe63acc51018199df4202" + +# Bump PV with every SRCREV move: the hash-derived package version is not +# monotonic on its own and buildhistory QA fails the build when it sorts +# backwards. +PV = "0.1.6+git" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc index e19eb59..d801fdb 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc @@ -10,13 +10,8 @@ LICENSE = "MIT" LIC_FILES_CHKSUM = "file://LICENSE;md5=62f8bb455fcc4bf177ecab380f71cd5d" SRC_URI = "git://github.com/ScottW514/python3-gfhardware.git;protocol=https;branch=master" -# Pinned; bump deliberately (AUTOREV is not reproducible). -SRCREV = "a0a174d8e9a6b4b6e3abe63acc51018199df4202" - -# Bump PV with every SRCREV move: the hash-derived package version is not -# monotonic on its own and buildhistory QA fails the build when it sorts -# backwards. -PV = "0.1.6+git" +# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass). +require forgefirm-app-pin.inc S = "${WORKDIR}/git" diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc new file mode 100644 index 0000000..b6a1c50 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc @@ -0,0 +1,6 @@ +# grblHAL-glowforge pin. Bump deliberately after pushing grblHAL-glowforge +# changes; keep only SRCREV and PV here - the image manifest leaves +# *-pin.inc out of the layer content hash because the component entry +# already identifies the pinned source (forgefirm-image-manifest.bbclass). +SRCREV = "67d026d39ac03286937774f5e28946b88c44d490" +PV = "0.1.0" diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb index f5b59d3..2be9aa9 100644 --- a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb @@ -4,14 +4,12 @@ HOMEPAGE = "https://github.com/ScottW514/grblHAL-glowforge" LICENSE = "GPL-3.0-or-later" LIC_FILES_CHKSUM = "file://COPYING;md5=3237e48bcef3455c7bea5c0ce16206f6" -PV = "0.1.0" - # gitsm: the grblHAL core rides as a submodule (ScottW514/core, branch # forgefirm = upstream master + the step_us_min buffer fix pending # upstream). SRC_URI = "gitsm://github.com/ScottW514/grblHAL-glowforge.git;protocol=https;branch=main" -# Pinned; bump deliberately after pushing grblHAL-glowforge changes. -SRCREV = "67d026d39ac03286937774f5e28946b88c44d490" +# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass). +require grblhal-glowforge-pin.inc SRC_URI += "file://grblhal.init" diff --git a/scripts/manifest-from-tree.py b/scripts/manifest-from-tree.py index a1e3615..1d0f17f 100644 --- a/scripts/manifest-from-tree.py +++ b/scripts/manifest-from-tree.py @@ -15,10 +15,13 @@ # [--cache DIR] [--kernel-srcrev REV] # # Component revisions come from the recipes in meta-forgefirm and the sibling -# meta-openglow checkout (default ../meta-openglow relative to this repo). -# Each pinned commit is fetched shallowly into --cache (default -# .manifest-cache/, gitignored) and listed with `git ls-tree`; a submodule -# gitlink is followed through .gitmodules. +# meta-openglow checkout (default ../meta-openglow relative to this repo); a +# recipe's `require`d files in its own directory are read too, which is +# where the pins live (-pin.inc). Each pinned commit is fetched +# shallowly into --cache (default .manifest-cache/, gitignored) and listed +# with `git ls-tree`; a submodule gitlink is followed through .gitmodules. +# The layer content hash mirrors forgefirm-image-manifest.bbclass: every +# file under the layer except *.md and *-pin.inc. import argparse import hashlib import json @@ -43,6 +46,9 @@ RECIPES = [ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"), "meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"), "meta-openglow-core": ("meta-openglow", "meta-openglow-core")} +# Left out of a layer's content, as in forgefirm-image-manifest.bbclass +# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files. +LAYER_SKIP_SUFFIXES = (".md", "-pin.inc") def git(args, cwd=None, input=None): @@ -50,8 +56,27 @@ def git(args, cwd=None, input=None): stderr=subprocess.PIPE, check=True).stdout +def recipe_text(path, seen=None): + """The recipe's text with its `require`/`include`d files from the same + directory appended (bitbake resolves a relative name against the + including file's directory first). Only local files are followed; + anything else is left to BBPATH and skipped here.""" + seen = seen if seen is not None else set() + path = os.path.abspath(path) + if path in seen: + return "" + seen.add(path) + with open(path, encoding="utf-8") as f: + text = f.read() + for m in re.finditer(r'^\s*(?:require|include)\s+(\S+)\s*$', text, re.M): + cand = os.path.join(os.path.dirname(path), m.group(1)) + if os.path.isfile(cand): + text += "\n" + recipe_text(cand, seen) + return text + + def parse_recipe(path): - text = open(path, encoding="utf-8").read() + text = recipe_text(path) uri = re.search(r'^SRC_URI\s*\+?=\s*"([^"]+)"', text, re.M) rev = re.search(r'^SRCREV\s*\??=\s*"([0-9a-fA-F]+)"', text, re.M) if not uri or not rev: @@ -116,7 +141,8 @@ def ls_tree(repo, rev, url, cache, prefix, files): def layer_content(path): out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], cwd=path) paths = sorted(set(p.decode("utf-8", "replace") for p in out.split(b"\0") if p)) - paths = [p for p in paths if os.path.isfile(os.path.join(path, p)) and not p.endswith(".md")] + paths = [p for p in paths + if os.path.isfile(os.path.join(path, p)) and not p.endswith(LAYER_SKIP_SUFFIXES)] if not paths: return None # hash-object --stdin-paths resolves against the repository top level