Skip to content

git-workflow — pin-intent-and-currency

Detail page of the git-workflow recipe card.

The pin contract — intent, the two structural rules, and the states

Section titled “The pin contract — intent, the two structural rules, and the states”

The umbrella records one commit per submodule (a gitlink) and checks it out DETACHED and clean. What that recorded value is ALLOWED to be, and whether it is the value the owning repo actually SERVES, is the pin contract. It lives in ONE verb — verb: git-submodules, owned by opencharly/plugin-task — whose mode is status | bump | verify | currency. The first three are the maintenance modes (the task table above; “task sync and the commit gate” below), and bump is the ADVANCE — it moves EVERY pin the umbrella declares, and it is the only mode of the verb that writes. currency is a first-class mode of the SAME verb, not a second implementation, and it is the mode that answers “is the umbrella current?”.

The mode is the CONTRACT, and it IS in a released binary. Measured 2026-10-23 on the built binary (2026.295.1709), the umbrella’s own task surface answers the question: charly task pins --param MODE=currency names each pin’s state and the producer-first leg order. So a reader who needs the question answered does NOT MEASURE it by hand: a hand-built <path>: <recorded gitlink> vs <remote default-branch HEAD> table is a second implementation of a verb that already answers it, and it drifts. Nor should the guidance carry the verb’s COUNTS: they are a LIVE reading of the org’s remotes, not a constant — the same binary can report a different number an hour later than it did before, because the remotes advanced in between. The counts belong to the verb’s own output, where they are stamped. Everything below states the shapes and the states as RULES rather than as a release note for a version.

currency reads NO checkout state, and that is deliberate rather than incidental. It compares the only two objects that can answer the question — the recorded gitlink and the REMOTE — so the index-vs-checkout seam (opencharly/opencharly#461) cannot arise in it by construction. Ancestry is the ONE read that needs local objects, and its ABSENCE is REPORTED (differs (no checkout to judge ancestry)) rather than folded into a verdict.

Every pin declares WHY it holds the value it does, in one of exactly two shapes, and they are not interchangeable:

intent kind what “current” means
upstream:<ref> LEAF the owning repo’s OWN ref, read from the REMOTE. No local state enters the answer, so a leaf cannot inherit another repo’s staleness.
derived-from:<path> EDGE the value is <path>’s recorded gitlink IN THIS REPO — so it is current exactly when that source is. When the source is not current the pin is blocked-by, and the producer leg is NAMED instead of discovered an hour later.

An entry in pin_map IS an edge — derived-from:<pinned_from> — and needs no intent: entry of its own. A path with neither an intent: entry nor a branch= in .gitmodules is unpinned: reported as such, never silently treated as a leaf. Conversely a path with no intent: but WITH a branch= does have an intent — upstream:<branch> (implicit: .gitmodules branch=) — because bump rolls every path outside pin_map to its own default-branch HEAD, so that IS what such a pin means; it was simply never written down. The output counts implicit leaves separately, so no intent stays silent even when every pin is current.

  1. An edge may not point at another edge — depth ≤ 2. The graph is acyclic BY CONSTRUCTION, not by a validation that can be skipped, and the check covers EVERY edge source — pinned_from AND each explicit derived-from:<path>. A source that is itself an edge (a pin_map entry, or a path that declares a derived-from: intent) is refused BY NAME (graph depth > 1: … an edge may not point at an edge). Checking only pinned_from leaves the explicit shape unenforced while the schema states the rule generally — which is the one shape a second edge is most likely to take. An arm per shape is not the same control as an assertion that a shape cannot bypass the rule. THIS rule’s control is its shape — one classified graph, walked over every edge, into which a depth-3 chain cannot be expressed — and only then is an arm per shape worth writing. The live bed is the other half, and it is the half that failed: it drove pin_map alone, i.e. the shape that was already guarded, and reached its PASS line without ever exercising the shape the contract had just introduced.
  2. Every leaf must be remote-verifiable. A leaf is judged against the remote ref, so a leaf no namespace serves is structural (ref-not-served), and an edge whose declared source records no gitlink is refused rather than inherited from nothing.

Those two rules are the whole difference from the consistency contract this replaces. A contract that is satisfiable at BOTH ends while both ends are stale is not a currency contract, it is a mirror — which is exactly how policy B held while the distro-* pins were behind their own main.

state fails? what it means, and what it obliges
cur no the recorded gitlink IS what the intent names. Obliges nothing.
behind no a LEAF whose recorded commit is an ANCESTOR of the served ref. The advance is available NOW: it is the next leg of the sequence, and the remedy is charly task sync + a PR.
blocked-by <source> (<state>) / blocked-by <source>:<twin> (<state>) no, when it names a leg an EDGE that cannot be advanced from HERE because its producer is not current: an explicit derived-from:<path> inherits <source>’s own state, while a pin_map edge carries the state of the source’s OWN pin at the twin — one hop further than the gitlink, which is the hop the old contract never took. Either way it NAMES the producer: make that producer current first, and this pin follows.
not-on-the-served-line no neither commit is an ancestor of the other. This is NOT a divergence and must not be read as one: charly’s history is squash-merged, so a pin taken before a merge is unreachable from the served ref while the tree is perfectly healthy — and a hand-pin to an unrelated commit yields the SAME two SHAs. The objects cannot tell the two apart, so the honest report is the class plus the ONE remedy right for both: advance the pin (charly task sync).
differs (no checkout to judge ancestry) no the SHAs differ and the ancestry read could not be made, because the checkout (or, for a twin, the nested checkout) is not there. Reported, never folded into a verdict — and a fresh session worktree is full of them, so this state is normal, not alarming.
unpinned YES no intent and no branch= — the pin cannot be judged at all. Obliges declaring one.
no-source-pin YES an EDGE whose declared source names no path, or records no gitlink in this repo’s index — there is nothing to inherit.
pin-mismatch YES an EDGE whose recorded gitlink disagrees with its source’s. The two sides are made to agree by the sanctioned verb (or by the producing repo’s PR), never by a hand-pin.
unmerged YES the recorded commit is AHEAD of the served ref — the pin names a commit the repo does not serve. A PR branch was pinned; re-point it at a MERGED ref.
ref-not-served YES a LEAF for which no branch or tag of that name is served by the submodule’s URL — the intent ITSELF is wrong.
no-gitlink / no-url YES the objects the contract rests on are missing from the index / .gitmodules.
blocked-by <source> (not a declared pin) / blocked-by <source>:<twin> (no ref declared) YES the edge names a producer that is NOT a declared pin, or a twin its source declares no branch=/url= for — so there is no leg to name and the pin is UNJUDGEABLE. The remedy is upstream: declare the producer, or give this pin its own upstream: intent.

Why behind and blocked-by PASS — and why the output leads with them

Section titled “Why behind and blocked-by PASS — and why the output leads with them”

The FAIL set is STRUCTURAL only, and “structural” has a precise meaning: what fails is a pin the workflow cannot JUDGE, or two sides that DISAGREE. Everything it CAN judge and finds merely not yet advanced PASSES and is reported — so behind, not-on-the-served-line, and a blocked-by that names a real producer leg are not failures: they are legs of a cross-repo sequence whose next step is ANOTHER REPO’S PR. A blocked-by that names NO leg is the exception and IS structural: there is no producer to wait for, so the only thing the reader can do is declare one.

Failing on them would make the gate permanently red on a healthy tree — and a gate that is always red trains every reader to ignore it, which is the mirror problem inverted. So instead of failing, the Message LEADS with the number of pins that are NOT current, then the intent counts, then every non-current pin with its path, state, intent and both SHAs:

currency: <N> of <M> pin(s) NOT current [<tally, e.g. "1 blocked-by <src>:<twin> (<state>)", "419 cur">]; intents: <L> upstream leaf/leaves (<I> implicit), <D> derived-from edge(s); NOT CURRENT: <path>: <state>; intent <intent>; recorded <sha> [; served <sha>] [(<which>)]; …; producer-first legs: <legs>

A green run can therefore never read as “everything is fine”. The count is the first thing in the string, and 0 of <M> is the only shape that means current. Measured against the REAL umbrella at 96195c1, with a charly built from the implementation at 745f6d7: 14 of 429 pins not current as of 2026-10-15 — 415 cur, 2 behind, 4 not-on-the-served-line, 2 differs (no checkout to judge ancestry), and the five unreachable distro-* pins each reporting blocked-by charly:box/<ns> (no ref declared) — 5 of them STRUCTURAL. The intent counts reconcile exactly — 423 implicit upstream: leaves + 6 derived-from: edges = 429 — and they are IDENTICAL to the counts taken before the round that changed the depth rule, which is how a reader can see that the change moved the RULE and not the classification.

The Message ends with producer-first legs: — the ORDER in which the sequence can actually advance:

  1. the leaves that can advance now, each behind leaf as <path> (<recorded> -> <served>);
  2. then the edges those leaves unblock, each blocked-by edge, verbatim.

The legs are joined with ->, and the two degenerate readings are DISTINCT: none — every pin is at the ref its intent names (nothing is stale) versus none reachable from this repo (this repo HAS stale pins, but every one of them is blocked by a producer it cannot reach). Read the legs as the work list, not as decoration: a blocked-by names the repo whose PR must land first, which is the same producer-first rule the umbrella’s cross-repo landing order states — producer PR → merge → tag → consumer pin bump → umbrella gitlink bump.

The intents belong in THIS repo’s manifest

Section titled “The intents belong in THIS repo’s manifest”

The umbrella declares its own intents in its charly.yml, next to the pins they describe, and the six distro-* edges are the ONE pin_map in the pins task — never a second copy. Two consequences worth knowing before editing either:

  • A distro-* pin cannot be advanced from the umbrella when charly’s own box/* gitlink is behind. The verb’s source of truth for the edge IS that gitlink, and charly declares intent for box/arch only — so its other five nested pins are not judgeable at all and report blocked-by charly:box/<ns> (no ref declared). The remedy is one hop upstream: declare the intent in opencharly/charly (its own box/*), and the umbrella’s pin then follows through charly task sync. Never hand-pin a distro-*.
  • The verb is domain-neutral by rule. It knows paths, gitlinks and refs, and nothing about what a submodule CONTAINS. A fact about a repository’s own composition rather than about a pin — a harness-config binding, a marketplace corpus family, a generated file — is that repository’s own gate’s to own, and asking the pin verb to see it would break its reusability for every other repository (the same reason its schema bakes in no org name and no distro list). See the harness-config half of an advance in references/umbrella-mechanics.md.