feat(oci): wire the storage-accounting sink in the composition root

Summary

The Container/OCI format package emits storage-accounting deltas from five sites — blob finalize, cross-repository mount, manifest PUT, manifest DELETE and blob DELETE — and every one of them reaches the pipeline through a *oci.CounterSink field on its handler. No composition root built a sink, so that field was nil at every site and every emit was skipped.

The effect is a timeliness gap rather than a permanent zero, and the note on #758 (closed) measured it on a deployed rig: an OCI push moved no counter at emit time, and the following reconciliation pass logged "counter_column":"size_bytes","persisted":0,"reconciled":591 and settled the columns from source. So a container repository's artifacts_count and size_bytes read stale for at most one reconciliation_interval rather than staying at zero. This wires the sink so they move at the operation, and lands the read the sink could not be built without.

  • datastore.ContainerBlobFootprintStore is the production oci.RepositoryFootprint. For one digest it answers how many bytes the namespace stores and whether the container repository still reaches it. Membership walks the two populations RepositoryReconcileStore.RecomputeSizeBytes walks — the layer digests a repository's container_blobs rows reference and the payload digests its container_manifests rows reference — because the emitted delta and the reconciled value have to agree about what the repository stores.
  • wireOCIWithStore pairs that store with the emitter wireAccounting already builds, constructs one oci.CounterSink, and passes it to the upload, manifest and blob handlers.
  • assertOCIOptionsWired (was assertManifestOptionsWired) fails boot when any of the three counter options is dropped, beside the config reader and the usage tracker.
  • oci.NewCounterSink now rejects a nil pointer boxed into CounterEmitter or RepositoryFootprint.

Governing ADRs

ADR-023 (code structure and enforcement) — conforms. The membership read is a consumer-side seam: internal/datastore declares no interface and satisfies oci.RepositoryFootprint structurally, because the reverse-dependency rule forbids a shared package importing a format package. The composition root is where the two are bound, which is the pattern the ADR prescribes. No SQL moved into internal/format.

ADR-007 (database schema) — two divergences, both named below. The rest conforms: ADR-007's own repository-footprint section describes exactly the two-arm container_blobscontainer_manifests walk this predicate takes, and its per-version reconciliation section sanctions reading blob_storage_blobs directly for a read that names its digest up front, which this one does.

docs/adr/007_database_schema.md line 2101 says repositories.size_bytes "decrements when garbage collection hard-deletes the last attachment of that sha256". That disagrees with the delete-time decrement this wiring makes live, and it is the one ADR sentence this change runs against, so it is named here rather than left for a reviewer to rediscover as an oversight. #762 holds the obligation to amend it, and it now carries both halves of that sentence and both formats that diverge from one: npm's missing first-attach gate on the increment half, and this format's delete-time decrement on the other. It was scoped to npm alone before, and closed on a condition the npm fix alone would meet, so the OCI half would have gone with it. No handbook merge request carries the amendment yet. While none is open, ADR-007 and the code disagree on that sentence and #762 is where the obligation lives; once one merges and the daily sync:adrs job runs, docs/adr/007_database_schema.md carries the corrected text. This change waits on neither event.

The second divergence is on the index inventory, and this change rests on it rather than causing it. ADR-007 says in several places that the hosted container tables carry no (namespace_id, blob_sha256) index, and that a cross-image manifest-payload lookup scans the namespace-pruned partition. index_container_blobs_on_ns_id_blob_sha256 and index_container_manifests_on_ns_id_blob_sha256 have existed since migrations 20260820224726 and 20260820224727, and the column choice below states its index rationale from them. #762 carries that obligation too, in its section "A second ADR-007 divergence, on the index inventory".

ADR-002 (storage deduplication scope) and ADR-011 (data reconciliation) — conform; no deviation found. Repository-scoped and namespace-scoped byte counts deduplicate at their own scopes, which is what the two membership answers keep separate.

Testing

Level File What it covers
Unit internal/datastore/container_blob_footprint_test.go Constructor and argument guards; the rendered statement walks both arms, keys on blob_sha256, takes no soft-delete predicate, and spells a partition-key equality for every partitioned table it touches.
Integration internal/datastore/container_blob_footprint_integration_test.go Each membership arm alone, both together, the two scoping predicates, the absent-blob answer, the three image-marker positions, and agreement with RecomputeSizeBytes. Also the plan: one partition of each of the four tables, and an Index Cond binding namespace_id and blob_sha256 on each arm.
Unit internal/format/oci/emit_test.go NewCounterSink rejects a typed nil in either argument, and in both.
Unit cmd/artifact-registry/wire_oci_test.go wireOCI rejects a nil emitter; assertOCIOptionsWired panics once per dropped counter option.
Unit internal/format/oci/emit_gate_internal_test.go Each of the three gated attach sites fills a one-slot sink, drives the site with a saturated cap, and asserts whether result=dropped moved. Each case is mutation-verified: deleting its gate turns that case red.
Integration cmd/artifact-registry/wire_oci_accounting_integration_test.go A blob finalize, a manifest PUT and a blob DELETE through the production mount move both scopes' counters in Redis.

The membership predicate is only provable against real rows, because a fake cannot show the two arms diverging — a manifest payload that is no image's layer is exactly the row a layer-only predicate misses. The three image-marker positions mirror TestContainerBlobStore_FindBlobInRepository_SoftDeletedImage, and the middle one reaches the opposite verdict on purpose: that read decides whether a mount source may serve a blob, this one decides what the repository stores, and a tombstoned image's bytes are still stored.

In the composition-root suite the repository byte assertion is the one that reaches furthest. That delta moves only on a first attach, and only the production RepositoryFootprint decides first attach, so a non-zero value there proves the footprint store was threaded and answered; a nil or failing footprint retires the delta and leaves the field absent.

That suite reads the Redis scope hashes rather than the repositories row, and the reason is worth stating because a reviewer will ask. A drained value and a value a reconciliation pass recomputed from source are identical in the column, so a column assertion cannot separate an emit that happened from one that never did — the pass would settle the same numbers and the case would pass with no sink wired at all. The live hash exists only because something emitted, which is what makes it the discriminator; the note on #758 (closed) names it and the river_job chunk row as the only two. Two absences in that test are load-bearing as a result, and its doc comment records both: nothing in it can run a reconciliation pass, because it stands up no jobs backend and calls neither accounting.RegisterRiverJobs nor accounting.RegisterAsynqHandlers; and nothing can drain the hash out from under the reads, because no chunk worker or drain trigger is registered and the Redis deployment is the test's own.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container (image postgres:17-alpine, matching GL_PG_CURR_VERSION: "17" from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing and methodology. Expand the row for the seed shape, rendered SQL, bound args, and raw plans.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
ContainerBlobFootprintStore.BlobFootprint Aggregate unique_blob_storage_blobs_on_namespace_id_and_sha256, index_container_blobs_on_ns_id_blob_sha256, index_container_manifests_on_ns_id_blob_sha256, pk_container_images 1 / 1 25.22 0.119ms 11 / 0 1/64, 1/64, 1/64, 1/64
ContainerBlobFootprintStore.BlobFootprint

Summary: The plan matches the method's intent. Every table comes back through an Index Scan, and all four partitioned tables prune to one partition of 64. The three indexes the method's doc comment names all appear, plus pk_container_images for the two joins. The EXISTS over UNION ALL becomes one uncorrelated InitPlan that runs once and short-circuits: a digest the layers arm holds leaves the manifests arm (never executed). No anomalies. Planning time is larger than execution time (2.547 ms against 0.222 ms cold, 1.098 ms against 0.193 ms warm), which is the price of plan-time pruning across four tables of 64 partitions.

Seed shape: namespaces=1, repositories=1, container_repositories=1, blob_storage_blobs=10000, blob_storage_attachments=10000, container_images=5000, container_blobs=5000, container_manifests=5000

The 10000 seeded digests all hash to one partition of blob_storage_blobs (satisfies_hash_partition, modulus 64, remainder 0), and blob_storage_attachments shares that strategy, column, and modulus. Digests 1-5000 are layer digests under container_blobs, and digests 5001-10000 are manifest payload digests under container_manifests, so the two EXISTS arms hold disjoint populations. The plan below binds a payload-only digest, which is the worst case: the layers arm runs, misses, and the manifests arm then runs. A second plan for a layer digest follows, and it shows the short-circuit.

Rendered SQL:

SELECT COALESCE(MAX(blob_storage_blobs.size), $1),
     EXISTS (
          (
               SELECT $2
               FROM public.container_blobs
                    INNER JOIN public.container_images ON ((container_blobs.container_image_id = container_images.id) AND (container_blobs.namespace_id = container_images.namespace_id))
               WHERE (((container_blobs.namespace_id = $3::uuid) AND (container_images.namespace_id = $4::uuid)) AND (container_images.container_repository_id = $5::uuid)) AND (container_blobs.blob_sha256 = $6::bytea)
          )
          UNION ALL
          (
               SELECT $7
               FROM public.container_manifests
                    INNER JOIN public.container_images ON ((container_manifests.container_image_id = container_images.id) AND (container_manifests.namespace_id = container_images.namespace_id))
               WHERE (((container_manifests.namespace_id = $8::uuid) AND (container_images.namespace_id = $9::uuid)) AND (container_images.container_repository_id = $10::uuid)) AND (container_manifests.blob_sha256 = $11::bytea)
          )
     )
FROM public.blob_storage_blobs
WHERE (blob_storage_blobs.namespace_id = $12::uuid) AND (blob_storage_blobs.sha256 = $13::bytea);

Bound args: [0, 1, b2fe290b-…, b2fe290b-…, c1ff658e-…, \x…07657b, 1, b2fe290b-…, b2fe290b-…, c1ff658e-…, \x…07657b, b2fe290b-…, \x…07657b]

The statement returned 8524, true, which is the seeded size of that digest's blob and a manifest-arm membership hit.

Plan (payload-only digest, both arms execute):

 Aggregate  (cost=25.21..25.22 rows=1 width=9) (actual time=0.117..0.119 rows=1 loops=1)
   Buffers: shared hit=11
   InitPlan 1
     ->  Append  (cost=0.56..33.24 rows=2 width=8) (actual time=0.079..0.081 rows=1 loops=1)
           Buffers: shared hit=8
           ->  Nested Loop  (cost=0.56..16.62 rows=1 width=8) (actual time=0.024..0.025 rows=0 loops=1)
                 Buffers: shared hit=2
                 ->  Index Scan using container_blobs_p30_namespace_id_blob_sha256_idx on container_blobs_p30 container_blobs  (cost=0.28..8.30 rows=1 width=32) (actual time=0.024..0.024 rows=0 loops=1)
                       Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (blob_sha256 = '\x…07657b'::bytea))
                       Buffers: shared hit=2
                 ->  Index Scan using container_images_p30_pkey on container_images_p30 container_images  (cost=0.28..8.30 rows=1 width=32) (never executed)
                       Index Cond: ((id = container_blobs.container_image_id) AND (namespace_id = 'b2fe290b-…'::uuid))
                       Filter: (container_repository_id = 'c1ff658e-…'::uuid)
           ->  Nested Loop  (cost=0.56..16.62 rows=1 width=8) (actual time=0.054..0.054 rows=1 loops=1)
                 Buffers: shared hit=6
                 ->  Index Scan using container_manifests_p30_namespace_id_blob_sha256_idx on container_manifests_p30 container_manifests  (cost=0.28..8.30 rows=1 width=32) (actual time=0.026..0.026 rows=1 loops=1)
                       Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (blob_sha256 = '\x…07657b'::bytea))
                       Buffers: shared hit=3
                 ->  Index Scan using container_images_p30_pkey on container_images_p30 container_images_1  (cost=0.28..8.30 rows=1 width=32) (actual time=0.025..0.025 rows=1 loops=1)
                       Index Cond: ((id = container_manifests.container_image_id) AND (namespace_id = 'b2fe290b-…'::uuid))
                       Filter: (container_repository_id = 'c1ff658e-…'::uuid)
                       Buffers: shared hit=3
   ->  Index Scan using blob_storage_blobs_p00_namespace_id_sha256_idx on blob_storage_blobs_p00 blob_storage_blobs  (cost=0.29..8.30 rows=1 width=8) (actual time=0.032..0.033 rows=1 loops=1)
         Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (sha256 = '\x…07657b'::bytea))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=105
 Planning Time: 2.547 ms
 Execution Time: 0.222 ms

Plan (same prepared statement, layer digest, manifests arm short-circuits):

 Aggregate  (cost=25.21..25.22 rows=1 width=9) (actual time=0.095..0.097 rows=1 loops=1)
   Buffers: shared hit=9
   InitPlan 1
     ->  Append  (cost=0.56..33.24 rows=2 width=8) (actual time=0.054..0.056 rows=1 loops=1)
           Buffers: shared hit=6
           ->  Nested Loop  (cost=0.56..16.62 rows=1 width=8) (actual time=0.054..0.055 rows=1 loops=1)
                 Buffers: shared hit=6
                 ->  Index Scan using container_blobs_p30_namespace_id_blob_sha256_idx on container_blobs_p30 container_blobs  (cost=0.28..8.30 rows=1 width=32) (actual time=0.024..0.024 rows=1 loops=1)
                       Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (blob_sha256 = '\x…027c1a'::bytea))
                       Buffers: shared hit=3
                 ->  Index Scan using container_images_p30_pkey on container_images_p30 container_images  (cost=0.28..8.30 rows=1 width=32) (actual time=0.028..0.028 rows=1 loops=1)
                       Index Cond: ((id = container_blobs.container_image_id) AND (namespace_id = 'b2fe290b-…'::uuid))
                       Filter: (container_repository_id = 'c1ff658e-…'::uuid)
                       Buffers: shared hit=3
           ->  Nested Loop  (cost=0.56..16.62 rows=1 width=8) (never executed)
                 ->  Index Scan using container_manifests_p30_namespace_id_blob_sha256_idx on container_manifests_p30 container_manifests  (cost=0.28..8.30 rows=1 width=32) (never executed)
                       Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (blob_sha256 = '\x…027c1a'::bytea))
                 ->  Index Scan using container_images_p30_pkey on container_images_p30 container_images_1  (cost=0.28..8.30 rows=1 width=32) (never executed)
                       Index Cond: ((id = container_manifests.container_image_id) AND (namespace_id = 'b2fe290b-…'::uuid))
                       Filter: (container_repository_id = 'c1ff658e-…'::uuid)
   ->  Index Scan using blob_storage_blobs_p00_namespace_id_sha256_idx on blob_storage_blobs_p00 blob_storage_blobs  (cost=0.29..8.30 rows=1 width=8) (actual time=0.034..0.035 rows=1 loops=1)
         Index Cond: ((namespace_id = 'b2fe290b-…'::uuid) AND (sha256 = '\x…027c1a'::bytea))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=3
 Planning Time: 1.098 ms
 Execution Time: 0.193 ms

Partition pruning: blob_storage_blobs 1/64 (blob_storage_blobs_p00), container_blobs 1/64 (container_blobs_p30), container_images 1/64 (container_images_p30, the same partition for both joins), container_manifests 1/64 (container_manifests_p30). Every partitioned table the statement touches prunes to one partition.

Index provenance: the plan names partition-local child indexes. Their parents are unique_blob_storage_blobs_on_namespace_id_and_sha256, index_container_blobs_on_ns_id_blob_sha256, index_container_manifests_on_ns_id_blob_sha256, and pk_container_images, confirmed through pg_inherits. The first three are the indexes the containerBlobFootprintStmt doc comment names.

Timings: planning 2.547ms, execution 0.222ms, total 2.769ms (cold plan). Warm re-plan of the same statement: planning 1.098ms, execution 0.193ms, total 1.291ms.

Caveats: The seed is small against production. 5000 rows per container table and 10000 per blob table sit in one partition each, where a real namespace can hold orders of magnitude more, so the absolute timings are a floor rather than a forecast. What the seed does establish is plan shape: at this cardinality the planner already prefers all four index paths over a Seq Scan, and every partition key binds tightly enough to prune to one partition. No planner settings were pinned. Planning time exceeds execution time by roughly 11x cold and 6x warm, because plan-time pruning considers four tables of 64 partitions each; the production driver plans this statement per execution rather than reusing a named prepared statement. EXPLAIN ran through PREPARE/EXECUTE, so plan-time pruning applies as it does for the extended-protocol path the store uses.

Migration mode did not run: this merge request adds no file under internal/datastore/migrations/sql/.

Why the constructor guard rides this change

A nil pointer boxed into an interface is a non-nil interface value. NewCounterSink(nilTypedEmitter, footprint) cleared the old nil-interface checks and every per-site nil check below them, and the failure then surfaced inside an emit, on an operation that had already committed its rows and answered its client. While no composition root built a sink nothing could reach that trap; this change is the first code that can, so the guard lands with it. The composition root also takes the concrete *accounting.Emitter rather than the interface, which is what makes wireOCI's own nil check effective — the arrangement mountSlugAnchoredFormats documents for the usage-data tracker.

WithUploadCounters had no test anywhere in the tree

oci.CompleteUpload and oci.MountBlob are package-level functions that take the sink as a parameter, and internal/format/oci's emit suite calls them directly with a sink of its own. So the emits were well covered and the option was not covered at all: WithUploadCounters sets h.counters, and nothing asserted that field is set or that it reaches those calls. Deleting the h.counters argument does not compile, because CompleteUpload and MountBlob take the sink as a mandatory parameter. Two regressions do compile. Dropping the oci.WithUploadCounters line leaves every emit test green, and UploadHandler.CountersWired plus assertUploadOptionsWired catch it at boot. Substituting nil for h.counters at a call site passes boot, and each site has its own cover: the composition-root suite watches the finalize move the counters in Redis, and TestMountThroughHandlerEmitsWithTheHandlersOwnSink in internal/format/oci/mount_test.go serves a mount through the handler and asserts the repository byte delta and the destination scope it was recorded against.

Predicate keys on blob_sha256, not digest

The integration suite's dbRepositoryFootprint double keys both arms on digest and justified it by index availability. That justification no longer holds: index_container_blobs_on_ns_id_blob_sha256 and index_container_manifests_on_ns_id_blob_sha256 landed in 0c42fdaa5, so on blob_sha256 both arms probe an index directly, where on digest only the blobs arm does and the manifests arm degrades to one probe per image. blob_sha256 is also the column the recompute reads, which is the value this predicate has to agree with. The double keeps its digest spelling deliberately, and its comment now says why: nothing in the schema ties the two columns, so two copies of one spelling would agree with each other and with neither recompute if a third writer ever set them apart.

Comments corrected alongside the code

internal/format/oci/emit.go (three sites), emit_dispatch.go (two), emit_internal_test.go, emit_integration_test.go and docs/dev/storage-accounting.md each said "while nothing dispatches a reconciliation pass". That is false, and the whole chain was checked rather than just the registration: accounting.RegisterRiverJobs registers the two reconciliation periodic triggers, accounting.RegisterAsynqHandlers registers the pass itself and is not the empty body it once was, and wireJobs calls both. The pass runs end to end, so each sentence now states the bound — the configured reconciliation interval — rather than an absence. internal/format/npm carries three more copies and internal/format/maven two, in files this change does not touch. #840 (closed) tracks all five, and names the maven pair as the worse one: each asserts accounting.RegisterAsynqHandlers has an empty body, and it does not.

e2e scenarios

docs/testing/e2e/oci.md gains e2e.oci.lifecycle.repository-storage-counters, the OCI counterpart of e2e.npm.lifecycle.repository-storage-counters. The catalog had no OCI counter scenario, because until this change the OCI counters did not move.

The comment-caps pass, and why the prose moved to a document

lint:comment-caps landed on main after this branch was cut, in b47c32fc3, and it caps every comment block a diff touches. This branch touched 66 oversized blocks across 18 files, so the gate blocked the merge and the branch has been rebased onto the main that carries it.

Sixty of those blocks carry storage-accounting reasoning, and that prose moved into docs/dev/storage-accounting.md rather than being deleted. Each site keeps a capped comment naming the symbol and pointing at the section: the comment points, the document states, and nothing is defined in both. The document gains 444 lines and three sections, and appends to the two it already had. Six blocks are about something else and were shortened in place; nine more were over cap on phrasing alone, with no reasoning to move, and were shortened the same way.

On docs/dev/go-style.md, which says pages under docs/dev/ "are written on purpose and are not overflow homes for comment text". That line is real and it points the other way. The choice here was made deliberately against it, because the alternative on these particular blocks was deleting reasoning that earlier review rounds on this merge request and on !1793 (merged) had settled — both directions the fail-retire arm leaves behind, why the unclamped negative column is accepted rather than clamped, why the in-flight cap carries no per-repository share, and what covers each of the two call sites that reach MountBlob. docs/dev/storage-accounting.md is the page that already documents this pipeline, so the material landed in the document that owns the subject rather than in a page invented to hold it. Read it as a considered deviation rather than an oversight, and say so if you disagree.

Two inbound pointers are knowingly left alone. internal/accounting/register.go and cmd/artifact-registry/wire_lifecycle.go each cite wireAccounting's godoc for the sync.Once residuals, and that godoc is now a pointer. Repointing them was measured and rejected: editing either file pulls its own 111-line and 44-line pre-caps block into the ratchet and fails the gate on a file this merge request otherwise does not touch. Both still reach the prose in one hop through wireAccounting's surviving pointer, and both restate their two consequences locally.

Linting note for reviewers

The golangci-lint pre-commit hook does lint //go:build integration files among the changed set, even though no CI lint job compiles them. The project guardrail reads as though integration-tagged tests are unlinted everywhere, and that is true of CI only. The new integration tests carry a function-level //nolint for that reason, matching lifecycle_reap_container_remote_integration_test.go. Every token was re-measured under --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false, and four contextcheck tokens that suppressed nothing were removed: three in container_blob_footprint_integration_test.go and one in wire_oci_accounting_integration_test.go, each on a t.Cleanup closure inside a helper that takes no context.Context. What survives is paralleltest with contextcheck on the two footprint cases that bind a context of their own, paralleltest alone on the two cases that do not, ireturn on containerBlobFootprintStmt, and containedctx on the table-driven case's ctx field.

Diff size

1949 insertions and 476 deletions across 27 files, past the 500 reviewable-LOC threshold. Splitting it would not help, for the reason each group below records.

Group Files Churn
Production — the membership read internal/datastore/container_blob_footprint.go 137
Production — composition-root wiring cmd/artifact-registry/wire_oci.go, wire.go, wire_accounting.go 348
Production — sink guard and accessors internal/format/oci/emit.go, emit_dispatch.go, upload.go, blob.go, manifest.go 207
Production — the zero-delta dispatch gate internal/format/oci/store.go, manifest_push.go 33
Tests — new container_blob_footprint_test.go, container_blob_footprint_integration_test.go, wire_oci_accounting_integration_test.go, emit_gate_internal_test.go 999
Tests — updated wire_oci_test.go, wire_oci_boot_integration_test.go, wire_oci_usagedata_integration_test.go, emit_test.go, emit_internal_test.go, emit_integration_test.go, queries_test.go, mount_test.go 241
Docs, run recipe and query-name catalog docs/dev/storage-accounting.md, docs/testing/e2e/oci.md, query_names.go, .claude/skills/run-artifact-registry/SKILL.md 460

Tests are 51% of the churn, and the two integration suites are most of that: the membership predicate and the composition-root threading are both only provable against real rows and a real Redis.

The production groups cannot land separately in a useful order. The store has no caller without the wiring, and shipping it alone adds an unreachable statement. The wiring cannot land without the store, since it is the sink's second argument. The guard is what makes the wiring's own failure mode surface at boot rather than inside a committed operation, so landing the wiring first ships the window the guard closes. The zero-delta dispatch gate is separable in principle, and it rides here because it is what makes the dropped-delta metric mean what docs/dev/observability.md says it means once these sites go live. The docs group is large because the comment-caps pass moved this pipeline's reasoning into docs/dev/storage-accounting.md; that is a relocation rather than new prose, and the Go files shed a matching 476 lines. The commits are split along those lines for review, and each is self-contained.

Closes #758 (closed)

Edited by Pawel Rozlach

Merge request reports

Loading
Loading