Skip to content

Epic: first-class multi-project support #72

Description

@koenkooi

The problem

mackas is structurally single-project. There is one root, one flat work/ that is KAS_WORK_DIR for everything, and one volume stem (oe-build) naming exactly three ext4 volumes. Building a second thing today means one of two bad options: a fully separate adopted root driven by --config <full path> on every single invocation, with its own private download cache; or sharing everything with the first project, including the flat work/, where kas's ordinary repos_checkout will silently reset one project's layer pins when the other builds — the same class of data loss the -k/--skip discipline exists to prevent, arriving through a path no --skip covers.

What is actually wanted:

  • Multiple projects with different volumes on one machine. meta-qcom and meta-ai side by side, each with its own TMPDIR, neither stepping on the other.
  • Optional sharing of downloads and sstate across projects, never concurrently. Sharing either cache must be available — it is neither a requirement of multi-project support nor a recommendation. Projects are fully private by default; a user who wants one DL_DIR, or one sstate store, opts in per project. A shared TMPDIR is never offered.
  • A UX that is not user-hostile. The project should be deduced from $PWD or from the location of the kas yaml files, not demanded as a flag on every command. In particular, hand-typed kas-container build meta-qcom/kas/base.yml:... — the primary real-world workflow — must land on the right volumes with no flag and no sourced environment.

Only a couple of pieces exist today. adopt already derives mackas-<name>-{tmp,dl,sstate} via adopt_unique_volume_name() and already writes a complete standalone config to ~/.config/mackas/projects/<name>.conf. Confirmed absent from the entire tree: MACKAS_VOLUME_DL_NAME, MACKAS_VOLUME_SSTATE_NAME, MACKAS_SSTATE_GROUP, mackas-shared-dl, cmd_build, and any --project global flag.

Full design reasoning (prior art comparisons, per-milestone rationale) lives in this issue and its sub-issues; nothing referenced here is off-GitHub.

The core architectural bet

Project identity is pinned once, explicitly. It is then selected implicitly, by structural matching.

  • Pinning happens only through a command the user types: mackas adopt <root> (exists) or the new mackas project add <name> (its in-root sibling). Both write the same artifact — a complete, standalone config at ~/.config/mackas/projects/<name>.conf.
  • Selection happens per invocation. With no explicit selector, physical $PWD (or the first path component of a kas chain argument) resolves to a project only when it lies inside a directory some already-pinned config points at.

Inference selects among identities the user already consented to; it never mints one. That is direnv's and mise's trust model with the consent moved to pin time, plus west's and repo's marker-based root discovery — and it makes the backward-compatibility argument hold by construction rather than by careful reasoning: a user with no pinned projects has nothing for inference to match, so every code path, file layout, volume name and KAS_WORK_DIR stays byte-identical to today.

The second half of the bet: "shared but not concurrently" needs no new mechanism at all. It is exactly what the one-VM-per-ext4-volume rule already enforces — once the held-volume refusal, a pre-existing invariant-3 gap on the build path, actually ships.

Two decisions from the earlier plan are carried forward verbatim because everything else rests on them:

  • --project / $MACKAS_PROJECT_SELECT is a config selector — it decides which single file load_config() sources — never a fifth precedence rung. defaults → config file → environment → --set is untouched; invariant 4 is literally unchanged. MACKAS_PROJECT_SELECT is deliberately not in SETTING_NAMES.
  • Volume identity keys off the selector, never off MACKAS_PROJECT_DIR. Every existing user already has MACKAS_PROJECT_DIR set, and item 25's _mackas_derive_project() sets it automatically for anyone driving kas by hand. Keying off it would silently relocate existing users' volumes mid-session. The selector surface is new, so keying off it cannot affect anyone who has not opted in.

The ~/oe/work rethink

A project workspace is work/<name>/, and it is that project's entire KAS_WORK_DIR — the config checkout and its kas-cloned layer siblings together, private to that project:

$MACKAS_ROOT/
  work/
    meta-ai/              # legacy checkout — still owned by the legacy (no-project) config
    openembedded-core/    # legacy kas-cloned sibling — untouched
    .repo-ref/             # shared git object store (KAS_REPO_REF_DIR)
    meta-qcom/             # PROJECT WORKSPACE for pinned project "meta-qcom" = its KAS_WORK_DIR
      meta-qcom/           #   the config checkout (mounted at /repo)
      poky/ meta-oe/ ...   #   kas-cloned layers, private to this project
  logs/meta-qcom/  env-meta-qcom.sh  macos-meta-qcom.yml

This dissolves the shared-work/ hazard structurally instead of by discipline. Key properties:

  • A first-level directory under work/ is a project workspace if and only if a pinned config for that name exists. Filesystem shape alone never decides anything. A legacy work/meta-ai/ with no pin is just a checkout, exactly as today. Consequence: project add <name> must refuse a name colliding with an existing work/ entry unless it is converting that very entry.
  • Clone duplication is paid in git objects once, via KAS_REPO_REF_DIR=$MACKAS_WORK/.repo-ref — kas's native, documented mechanism (validated at kas-container:242, forwarded rw as /repo-ref at :634, read by kas/context.py:88). It ships late because it is unverified over virtiofs. The design works without it, just fatter (~2–5 GB per project).
  • MACKAS_SHORT_LINK is unchanged. ~/oe still points at the one root. Projects are subdirectories of a root's work/, not new roots; adopt remains the tool for genuinely separate physical roots, and adopted roots' projects participate in selection identically, because selection reads pinned configs and not the short link.
  • The work/meta-qcom/meta-qcom/ stutter when repo name equals project name is cosmetic and accepted — it is what makes the layout uniform.

Project identity and selection

Selector precedence, most explicit first, exactly one winner:

  1. --config <path> / --project <name> (mutually exclusive)
  2. $MACKAS_CONF / $MACKAS_PROJECT_SELECT (mutually exclusive)
  3. Derivation from physical $PWD (new)
  4. Default search path (~/.config/mackas/config, ~/.mackas.conf) — today's behavior, and the terminal fallback

The derivation rule, precisely: compute pwd -P (physical paths on both sides, which defuses the ~/oe symlink problem); enumerate ~/.config/mackas/projects/*.conf and grep, never source, MACKAS_ROOT out of each; form <root>/work/<name>; prefix-match. Zero matches falls through to tier 4 — today's behavior verbatim. Exactly one match selects it. More than one — only reachable pathologically, e.g. an adopted root nested inside another root's work/dies listing the candidates. Grep-not-source is load-bearing for invariant 5: no candidate executes during selection, and the single winner is sourced only after config_file_is_safe() passes, unlike the typed --config path which deliberately skips it. A derived path under a predictable, name-guessable location is an ambush surface, not a request.

Special case for the hand-typed flow: standing in work/ with a kas chain in hand, the first path component of the first colon-entry names the workspace — kas-container build meta-qcom/kas/base.yml:... derives project meta-qcom iff pinned, else derives nothing.

Why the wrapper gets this for free. bin/kas-container re-invokes mackas runtime-args as a fresh subprocess, which inherits cwd — so tier-3 derivation works inside it automatically. A hand-typed kas-container build ... from inside a pinned workspace gets that project's volumes with no flag, no sourced env, no shell function. This is the single biggest non-hostility win in the design and it covers the primary workflow.

Selection fails closed, always. The failure modes that matter:

  • Workspace renamed or moved after pinning → prefix match fails → fall to tier 4, and mackas projects shows the stale pin with a suggested repair. Identity is never re-minted from a new directory name — that is docker-compose's orphaned-volumes lesson, and this design refuses to repeat it.
  • cwd in an unpinned legacy checkout → no derivation. Not a project. Today's behavior exactly.
  • Nested candidate workspaces → multiple matches → die listing both; adopt additionally warns at pin time when it would create the nesting.
  • Chain spans sibling workspaces → derive nothing (existing deliberate behavior; the sibling is outside the /repo mount anyway).
  • Non-interactive invocation where derivation would matter but fails → die with the candidate list. Never prompt, never guess.

The residual risk is not a wrong guess but a stale correct one — a project pinned long ago and forgotten. Mitigation is a once-per-shell note naming the project, the tier it resolved from, and the three volumes, plus mackas status/mackas projects always showing the resolution.

Volume model

Volume Unpinned (today) Pinned project <name> Override
tmp (/build) oe-build-tmp mackas-<name>-tmpalways private MACKAS_VOLUME_NAME (stem)
dl (/downloads) oe-build-dl mackas-<name>-dlprivate by default; MACKAS_VOLUME_DL_NAME=mackas-shared-dl opts into sharing MACKAS_VOLUME_DL_NAME
sstate (/sstate) oe-build-sstate mackas-<name>-sstateprivate by default; MACKAS_SSTATE_GROUP=<g>mackas-sstate-<g> MACKAS_VOLUME_SSTATE_NAME

Nothing is shared unless asked for. All three volumes are private to a project by default, so adding a second project never changes what the first one reads or writes. Sharing is offered for downloads and for sstate because both are genuinely safe to share — downloads are upstream tarballs and git mirrors keyed by name+checksum, so a bad tarball fails checksum verification at fetch time no matter who wrote it, and sstate is task-input-hash keyed. Safe to share is not the same as worth sharing: under the one-VM rule a shared volume is a contention point, so sharing trades disk against sibling projects queueing on each other. That trade belongs to the user, per project, which is what the knobs are for. Neither cache is shared by default and neither is recommended.

This declines bitbake-setup's sharing convention, knowingly. bitbake-setup's site.conf ships common-sstate=yes — shared sstate by default across Setups — and shares downloads by convention too. That is right on a Linux host, where concurrent builds against one cache are normal and sharing costs nothing but disk. It is wrong here: under the one-VM rule a shared volume means sibling projects queue on each other, a cost that simply does not exist upstream. bitbake-setup's "build trees always private" half transfers directly and is adopted; its shared-by-default half becomes an opt-in. The mirror addendum below is what makes that affordable — an HTTP mirror delivers cross-project hit rate without the contention, so declining shared-by-default costs much less than it otherwise would.

BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}" already travels with the sstate volume, so a shared group shares its hash-equivalence DB coherently, and the one-VM rule serializes SQLite access. No new mechanism. New name knobs get the same no-spaces validation MACKAS_VOLUME_NAME has, because they land unquoted in the word-split --runtime-args string.

Enforcement of "not concurrently" is the existing invariant, finally enforced on the build path: before any command that mounts volumes, resolve the three names, check each against running containers, refuse a held one — naming the holder and, by reverse-grepping the pinned configs, which project holds it ("mackas-shared-dl is mounted by a running build of project meta-ai"). Sharing makes collisions likelier and changes nothing about their handling. ro remains explicitly not an escape hatch.

Destruction safety. mackas destroy under a project selector destroys only that project's private volumes. A shared volume is refused with the list of pinned projects referencing it and requires the explicit mackas volume destroy <name> form. Same rule for the clean variants that touch dl/sstate — do_cleanall/do_cleansstate against a shared cache is a documented upstream footgun, and while the one-VM rule means there is no concurrent victim, a clean can still delete artifacts another project relies on.

Migration — two supported populations, nobody is forced to move:

  • (A) Do nothing. Stays on oe-build-* indefinitely. Fully supported, not deprecated.
  • (B) Become a pinned project. mackas project add <name> --from work/<checkout> converts in place, asking the one question that matters at pin time: keep oe-build-* (a supported end state, no data moves) or migrate to mackas-<name>-*. Nothing is ever renamed or moved silently.

The sharing knobs are deliberately not a migration route. MACKAS_VOLUME_DL_NAME does work standalone from the first milestone, so someone who has already decided they want one download cache can set it and seed via volume duplicate — but that is an opt-in for a user who went looking for it, not a path anyone should be walked down.

Addendum: two-way sstate mirroring

Multi-project makes the mirror story more important, because volume sharing has a structural ceiling: a shared sstate volume serializes builds. An HTTP mirror is a second sharing plane with the opposite properties — plain concurrent GETs, so N projects (or N machines) consume one warm cache simultaneously with zero volume contention. The two compose: a project keeps its fast private mackas-<name>-sstate and still gets cross-project hits via SSTATE_MIRRORS.

The read direction is already shipped — do not rebuild it. mackas-mirrord serves sstate and downloads read-only over HTTP with path validation, a credential store, rate limiting and the CacheManager hot-object accelerator; MACKAS_USE_HTTP_MIRRORS=1 plus MACKAS_HTTP_MIRROR_SSTATE/_DL generate the fragment; mackas check live-probes reachability; NFS-vs-HTTP ambiguity is a hard die. What multi-project still needs here is small and mostly free: per-project mirror URLs are just settings in the standalone per-project configs, seeded at pin time. MACKAS_SSTATE_GROUP deliberately does not imply a mirror URL — a group names a volume, a URL names a network endpoint, usually on another machine; deriving one from the other is exactly the magic this design refuses. Default mirror layout is one flat shared tree (<mirror>/sstate/), matching the upstream public mirror's all/ convention, because hit rate is the entire point and the hash-safety analysis says a flat tree is safe.

The publish direction does not exist anywhere and is the new work: mackas sstate push.

Surface: a new sstate subcommand, not a sixth retrieve object. retrieve's contract is "copy build products out of the tmp volume for the user"; all five objects live in MACKAS_VOL_TMP and the result belongs to the user. sstate lives in its own volume and the goal is publication, not inspection. So fetch_tmp_subdir() generalizes to fetch_volume_subdir(<volume>, ...) — a parameter change, nothing in its mechanism is tmp-specific — and cmd_sstate() gains push beside prune. Reuse the machinery, don't expose the verb.

One push:

  1. Resolve the sstate volume from the selector (private or group, both legitimate). volume_in_use → refuse.

  2. Throwaway container mounts the volume read-only plus a host staging dir; copy only objects newer than the last-push stamp (host-side stamp per volume+destination pair, find -newer). A lost stamp means a full rescan — merely slow, never wrong, because of --ignore-existing. Self-healing.

    Correction (2026-08-24): the "sstate objects are write-once" premise this originally rested on is false. bitbake touches an object's mtime every time it reuses it — sstate.bbclass's sstate_eventhandler and the setscene-valid check both call oe.utils.touch()/os.utime() on a hit, and mackas's own sstate prune is built entirely on that fact. So -newer selects everything the last build reused, not only what is new. The error direction is safe — it over-selects, never misses, and --ignore-existing makes the wire cost nil — but each over-selected object is still byte-copied into host staging and CRC-verified there, so a push is materially heavier than "incremental" implies, and staging must be sized for the build's reuse set rather than just its new objects. Anything better needs a signal other than mtime (a content index, or objects recorded at write time).

  3. Run the existing manifest verification (retrieve_verify_script() in-container, retrieve_verify_local() on the host) over the staged copy. The volume is released here — the network transfer happens with nothing mounted.

  4. Two-pass rsync --ignore-existing over ssh into the directory mackas-mirrord serves: pass 1 everything except *.siginfo, pass 2 the siginfo files.

  5. Update the stamp only after rsync exits clean.

This resolves issue #1's "push from the volume or from a host-side copy" question: from a host-side copy. Three reasons in order: the hardened copy-with-verification path (chunked cksum manifests, source/dest comparison, tar fallback, hard die on double mismatch) exists only there, and it exists because a real >18 GB artifact was once copied with the right size and wrong content — publishing to a shared mirror is the last place to skip that; push credentials stay on the host, keeping the throwaway container network-free and credential-free; and pushing from inside the container would put a long network transfer inside the window where the volume is held.

Transport: rsync over ssh, and specifically not HTTP PUT. ccache/sccache push naked PUTs with no documented integrity story and get away with it only because hash-keyed consumers ignore bad entries. Bazel/REAPI and actions/cache run real two-phase protocols with the right properties, but need a protocol-speaking server. rsync-to-the-served-directory gets Bazel's properties without the server: --ignore-existing is a filesystem-native FindMissingBlobs (never re-upload, never overwrite — published objects are immutable, first committer wins), and rsync's default temp-file-then-rename gives per-object atomic visibility — the same same-directory-rename pattern sstate.bbclass itself uses. Never --inplace. Adding PUT to mackas-mirrord is rejected outright: read-only-ness is the security property being asked for. Write path authenticated out-of-band over ssh, read path anonymous and RO — that split is the design.

Two concurrent pushers need no locking: --ignore-existing makes it first-writer-wins, and two pushers racing on the same hash-derived path are pushing identical bytes anyway. The two-pass ordering guarantees a consumer never sees a .siginfo for an absent payload.

Prune ordering — taking the position #1 left open: push first, prune second. Prune-then-push buys nothing (--ignore-existing plus stamp-based staging already keeps pushes incremental) and costs the mirror objects it would have archived. Push-then-prune inverts the risk profile of the whole aging problem: once an object is on the mirror, pruning it locally downgrades from "forced rebuild" to "HTTP refetch". That also defuses the items-36/45 objection to sstate_prune()'s wall-clock cutoff — against a pushed volume an over-aggressive cutoff is a performance bug, not a correctness one — so the relative-cutoff fix stops gating the push path.

Trigger: mackas sstate push ships explicit. MACKAS_SSTATE_PUSH_AUTO=1 (after a successful build) comes later, matching every CI precedent, with the difference that mackas adds the staging verification those pipelines lack.

Milestones

All work for this epic targets the multi-project branch, not main. Milestones PR into that integration branch and it merges to main once the epic is ready, so a half-landed epic never sits on main. CI covers both: pull_request is unfiltered so every milestone PR is gated, and multi-project is in the push trigger so the integration branch gets a post-merge run too.

Tracked as sub-issues of this epic. The project track is strictly ordered; the mirror track depends only on M1 and is severable — multi-project is what makes retrieve-and-push important, not what makes it possible, so it can run in parallel or even ship first.

Project track (strictly ordered, M3 is the backward-compatibility-critical milestone):
#74 (M0) → #75 (M1) → #76 (M2) → #77 (M3) → #78 (M4) → #79 (M5) / #80 (M6) → #81 (M7)

#82 (M8) closed in favour of #100 — seeding by clone dissolves the sharing groups it specified; its machine-wide disk accounting is folded into #100.

Mirror track (depends only on #75/M1, otherwise independent):
#83 (MP1) → #84 (MP2) / #85 (MP3)

Transition UX (needs #76/M2 and #77/M3; lands alongside #79/M5):
#90env.sh project selector when derivation cannot resolve, re-derived on cwd change

Volume lifecycle (reshapes #82; raises the priority of #91):
#100 — on-demand project volumes: lazily created, kept, optionally seeded by clone

Resource safety (needs #77/M3, since parallel builds only become normal once volumes are private):
#91 — refuse a build when the host has no CPU/RAM headroom for a second project

See each sub-issue for scope, size, and risk; blocking relationships are set natively on each issue.

Open questions

Genuinely undecided, as distinct from everything above, which the design commits to:

  1. KAS_REPO_REF_DIR over virtiofs is unverified. The whole "per-project workspaces without duplicating poky-sized clones" story rests on it. Fallback is full per-project clones — safe, ~2–5 GB extra each. Decide at M6 with data in hand, not before.
  2. Selector variable naming. Decided: MACKAS_PROJECT_SELECT. The selector is now --project <name> / $MACKAS_PROJECT_SELECT. The rejected option was reusing MACKAS_PROJECT_NAME, which reads as a sibling of the derived, informational MACKAS_PROJECT / MACKAS_PROJECT_DIR that _mackas_derive_project() exports — and the distinction between those two things is load-bearing, not cosmetic: volume identity keys off the selector and must never key off MACKAS_PROJECT_DIR, which every existing user already has set. A name that blurs the two invites exactly the confusion the whole backward-compatibility argument depends on avoiding. _SELECT says what it does — it chooses a config file — and cannot be mistaken for a description of the project. Decided before M2 (M2: The project selector, read-only #76) ships it, while the cost is zero: the name appears in no code, test or doc yet.
  3. Standalone per-project configs vs. layering. The design accepts repeating machine-wide preferences per project file to keep the precedence chain at four rungs; project add mitigates it by seeding from effective settings. A global-then-project source order is genuinely more convenient and genuinely a fifth rung. If config drift across many projects becomes painful, that is a future invariant-amendment discussion — not something to smuggle in.
  4. sstate default: private vs. shared. Decided: private, and so are downloads. Prior art (bitbake-setup's common-sstate=yes) leans shared, but that reasoning assumes a host where concurrent builds against one cache are normal; the one-VM rule makes any shared volume a contention point. Both caches are therefore private by default and sharing is opt-in per project — not required, not recommended. The mirror addendum is what makes this cheap, delivering the hit rate that was the main argument for sharing without the contention that was the argument against. What remains genuinely open is narrower: whether project add should mention the sharing knobs at pin time, or leave them to the docs.
  5. TOCTOU on the held-volume check. A flock on container-volumes/<vol>.lock would close the window between two invocations starting simultaneously. Cost: another state file that goes stale after a crash and needs repair handling. Deferred as hardening; the single-user Mac reality makes the window mostly theoretical.
  6. Shared-cache garbage collection. A long-lived mackas-shared-dl / mackas-sstate-<g> only grows. Whether a volume prune-style aging policy is needed, and on what signal (atime is unreliable on ext4-in-image), is unresolved. The existing per-volume sstate-prune machinery may simply suffice when pointed at shared names.
  7. Disk budget. ~680 GB worst case for four projects with private tmp/sstate plus shared dl remains unmeasured. M8's aggregate accounting produces the number; nothing commits to a budget before then. volume resize grows a volume now, so a conservative cap is no longer a one-way door.
  8. Should mackas set follow derivation? The design says yes, with the written file echoed on every set. The conservative alternative — require an explicit --project for any write — trades a small surprise risk for friction on the most common tweak. Flagged because it is the one place a derived selection writes rather than reads.
  9. Flat shared mirror tree vs. per-group subtrees as the default. Flat maximizes hit rate and is hash-safe; per-group mirrors the private-by-default philosophy and keeps one project's container-image-version fragmentation out of another's listing. Cheap to change before anyone depends on the layout, expensive after.
  10. Cross-machine hash equivalence. BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}" travels with the volume, so same-machine consumers share equivalence state — but a mirror consumer on another machine has its own DB and misses OEEquivHash-mediated hits. Upstream pairs SSTATE_MIRRORS with BB_HASHSERVE_UPSTREAM; whether the mirror host should also run bitbake-hashserv, and whether kas-in-container can reach it cleanly over vmnet NAT, is unverified and could materially cap the cross-machine hit rate.
  11. Staging disk cost. Stage-then-verify-then-push doubles the transient footprint of new objects. A streaming tar | ssh path would eliminate it but forfeits the compare-two-manifests-then-retry structure that caught real corruption. Measure first-push sizes before optimizing.
  12. Mirror-side GC. The mirror only grows, and it lives on a host the throwaway-container shape doesn't reach. Options: a prune mode in mackas-mirrord, a cron'd find on the mirror host, or explicitly out of scope. Overlaps Resilient caching bridge for sstate/downloads mirrors on unreliable network links #38's sqlite last-access work, which would supply exactly the usage data flat mtime lacks — the two should land coherently.
  13. Downloads push (MP3) is deferred only for sequencing, not because anything is unresolved.
  14. Push after a failed build. Completed tasks' sstate objects are individually valid regardless of overall outcome, so pushing them is hash-safe in principle. Auto-push gates on success (every CI precedent does). Whether an explicit sstate push against a failed build's volume should proceed silently or warn is a small UX call left open.
  15. Stamp semantics with multiple destinations. One stamp per volume+destination pair handles the obvious case; whether anyone genuinely pushes one volume to two mirrors is unknown. Costless to defer — the stamp is keyed, not global.

Relationship to existing issues

Status

In progress. Design researched against real-world kas usage and workspace-tool prior art (west, repo, direnv, mise, docker-compose, bitbake-setup, plus ccache/sccache, Bazel REAPI, actions/cache and the upstream autobuilder for the push side), grounded against the real mackas source and the pinned kas upstream, and reviewed for backward compatibility against the existing config-precedence invariant — which this design leaves literally unchanged.

Implementation is underway on the multi-project branch. Landed and merged: M0 (#74), M1 (#75), M2 (#76), MP1 (#83). M8 (#82) is closed as superseded, not shipped — its scope (sharing-group seeding) was folded into #100, which is still open. M3–M7 and MP2–MP3 remain unimplemented.

An independent review of what's landed (2026-09-02) found three bugs, filed as #106 (set/unset lockout on an invalid volume-name setting), #107 (sstate push's incremental stamp uses the host clock against build-VM mtimes, so clock skew silently drops objects from future pushes), and #108 (SKILL.md's --skip decision procedure probes git log @{u}..HEAD, which breaks on detached HEAD / no upstream — a state kas commonly leaves repos in).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Tooling and hygieneenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions