git-workflow — multi-repo-coordination
Detail page of the git-workflow recipe card.
B2 — multi-repo / multi-worktree coordination
Section titled “B2 — multi-repo / multi-worktree coordination”One logical change spanning several repos uses the same feat/<slug> in each
(main, sdk, plugins, docs, box/<distro>), so the branches correlate. R10 runs
against the assembled superproject (submodule pointers at the feat/ commits)
— the whole change is verified before any PR is opened. Then land in dependency
order, each repo as its OWN two-step PR (author opens; fresh pr-validator
merges + tags):
- the sdk contract repo (
github.com/opencharly/sdk— NOT a submodule; this repo consumes it from the module proxy at the pinned require version, so only a touched CONTRACT lands there) — PR → native auto-merge + tag-on-merge → tagv0.<YYYYDDD>.<HHMM leading-zeros-stripped>(its Go-module tag scheme; the superprojectvYYYY.DDD.HHMMform is not a valid Go module version — e.g. superprojectv2026.185.0751⇄ sdkv0.2026185.751) — whenever the cutover touched sdk content; - each
box/<distro>submodule — PR → auto-merge + tag-on-merge tags (it hascharly.yml); plugins— PR → auto-merge + tag-on-merge tagsv<YYYY.DDD.HHMM>(nocharly.yml, so no schemaversion:bump — but the tag marks the merge, same as every repo);- the superproject — stage the now-MERGED submodule pointers (a touched sdk:
adopt the tagged sdk release as the new shared pinned require in every
module —
task mods:tidyre-syncs go.sum and the canonical-go.mod gate asserts the ONE shared pin; there is no gitlink and noreplace) → PR → native auto-merge + tag-on-merge tagsmain.
A producer PR must be merged (not merely green) before the consumer’s pointer
bump — the superproject pointer must reference a commit that is on the submodule’s
real main, which only the merge produces.
A valid base is an ASSEMBLED PAIR, not a lone submodule advance. A new consumer
cutover branches from a base that is valid only once BOTH halves have merged: an sdk
main advance is a valid consumer base ONLY after its superproject adaptation (the
tagged sdk release adopted as the new shared pinned require — there is no gitlink) has
ALSO merged. Branch a consumer off a bare
sdk main advance whose super side is still open and you pin a superproject state no
main records — the consumer’s R10 builds against a half-assembled base and its pointer
bump references a commit main has never seen. Wait for the pair before treating a
producer advance as a base.
Submodule-pointer-bump safety (step 3) — bump AFTER the session worktree exists,
then stage AND verify. A git switch / git checkout — and equally the
git worktree add that creates a session’s landing branch — re-materializes each
submodule at the gitlink the target records, silently discarding an unstaged
working-tree pointer bump (it happens even with submodule.recurse unset — an
unstaged gitlink is not carried across the switch / worktree-add). So bumping the
pointer before creating the session worktree (git worktree add .claude/worktrees/<slug> -b feat/<slug> origin/main, B1 step 0 — the feat branch
is created there at session start, never by git switch -c in the shared main
tree) — or merely git -C <sub> checkout <new> without git add — drops it from
the commit, and a git add <sub>; git commit afterward stages nothing because the
working tree was reset to the old pointer. Always, in order: (a) create the
session worktree + feat branch FIRST (B1 step 0); (b) THEN git -C <sub> checkout <new-commit> + git add <sub>; (c) VERIFY it is staged — git diff --cached --submodule=short <sub> must print <old>...<new>; (d) after committing,
confirm the commit records it — git show --stat lists <sub> and git ls-tree HEAD <sub> shows <new>. A pointer-bump commit whose --stat omits the submodule
is the silent-drop failure.
Attribution of the pointer-bump commit — derived from what it points at. When
the bumped submodule commit is itself all-documentation (a skill / *.md edit),
the superproject pointer-bump commit IS the Documentation-only change class and
lands at documentation reviewed: the fresh validator inspects the submodule’s
own old..new diff to certify it. A bump that integrates submodule CODE is a
code class and takes a runtime tier, the docs riding along. So a docs-only skill
cutover lands plugins (the *.md) at documentation reviewed, then the
superproject pointer bump at documentation reviewed too — both halves honest.
The derivation stops at the gitlink, and the reason is a general rule: a change
class must be decidable FROM THE DIFF. A gitlink is a pointer git itself
resolves, so “what it points at” is reachable from the PR — the validator reads
the submodule’s own old..new in the repo the PR is against, and the derivation
above is certifiable without leaving it. A sha inside a candy var: is not: it
is a string, and following it means cloning a remote repository at a named
commit. Were “the referenced content is documentation” the test, the class would
be a property of a tree the PR does not contain, and no validator could rule on
it from the PR — which is why re-pinning DOCS_REF after a docs merge is a
CONFIG change carrying its own bed gate (B6a step 4), no matter how documentary
the commit it names. Any pin the diff cannot resolve behaves the same way. When
the diff cannot decide the class, the class is the heavier one.
For the full multi-worktree end-to-end — the doc-tier git -C literal-path
rule, and the mandatory post-landing worktree refresh — see B7.
Per-module verification — verify by MODULE CLASS, and prove the fix is in the BINARY
Section titled “Per-module verification — verify by MODULE CLASS, and prove the fix is in the BINARY”Two mechanics that bite every cross-repo landing:
Verify each Go module by its CLASS, always with GOWORK=off (the repo is a Go
workspace, so module-level checks must disable it or they pull the workspace’s transitive
requires):
sdkis a STANDALONE-CONSUMED contract module (out-of-tree consumers import it directly), so it earns the full standalone battery:GOWORK=off go mod tidy && go mod verify && go build ./... && go test ./.... It MUST tidy and build cleanly on its own.charlyis a WORKSPACE MEMBER, not a standalone module: verify it withGOWORK=off go mod verify+ the WORKSPACE build. A full standalonego mod tidyoncharlyPOLLUTES itsgo.modwithcandy/plugin-*pseudo-requires (they resolve through the workspace, not the module graph), and a standalonego buildthat fails ONLY oncandy/plugin-*imports is an ARCHITECTURAL fact (the plugins are workspace siblings), never a defect to “fix” by hand-editinggo.mod.pluginscandies tidy PER-MODULE — each candy is its own module; tidy/verify them individually, never as one tree.
Prove a fix is in the BUILT BINARY by a content marker, NOT by the version stamp.
scripts/calver.sh derives the CalVer from the HEAD commit’s UTC time (TZ=UTC0 git log -1 --format=%cd --date='format-local:%Y %j %H %M' — the TZ=UTC0 is what makes
the bare %cd UTC; without it %cd uses the commit’s own TZ offset), so the stamp
identifies the SOURCE COMMIT, never the build moment — a
task build:binary on a DIRTY working tree reports the IDENTICAL version as the clean
commit under it. So charly version matching the expected CalVer does NOT prove your
uncommitted fix compiled in. Prove fix-presence by a content marker instead: strings bin/charly | grep '<a string unique to the fix>' (a new error message, flag name, or
symbol). The stamp answers “which commit”; the strings marker answers “is my change
actually in this binary”.
B3 — agent teams in per-teammate worktrees
Section titled “B3 — agent teams in per-teammate worktrees”When an agent team parallelizes work, each teammate works in its OWN worktree
under .claude/worktrees/<slug>/ (B1 step 0) — the same worktree-per-session
model every session uses, never a shared checkout. A team is N worktrees, each
with its own bin/charly and its own freshness-guard scope; there is no
shared-tree alternative. Within a worktree, the check bed is the unit of
isolation: each teammate owns a disjoint check bed’s SOURCE files; distinct
beds get distinct charly-<bed> container/VM/domain names, and a bed run tags
every fixture IMAGE it builds with a per-run <bed-root>-<runCalver> tag (#75)
so two beds building the SAME fixture image name never race the store-global tag
namespace; host-port disjointness is not statically guaranteed, so every bed
uses port auto-allocation — never a hardcoded host port (the loader checks no
ports, so a collision surfaces only at deploy; manual port-picking is
forbidden), and a bed pins an
image → layers → files, so bed-ownership already isolates the source files each
teammate edits. Teammates edit; a PERSISTENT owner runs every full charly check run <bed> as a run_in_background task — the lead’s persistent
session, a background agent, or (interactive tmux) a split-pane teammate; an
in-process teammate CANNOT (its bg dies on yield).
- Teammates edit their bed-scoped files + run short foreground checks (
charly check box) — never the fullcharly check run, and never commit, push, or open a PR. The lead runs the full beds and, on R10 PASS, opens the SINGLE PR for the cutover (B1 step 1); a FRESHpr-validator(never a teammate that authored code) merges it. - Schedule longest-pole-first.
charly check runhas no bed-level concurrency and nocharlycap — the limit is host CPU/RAM/podman. Run ALL full beds as concurrent background tasks; order by expected DURATION, not bed count: launch the slow VM/desktop beds first and overlap the cheap pod beds, so wall-clock ≈ the slowest single bed, not the sum. - No shared freeze barrier — per-worktree self-freeze instead. Because each
worktree carries its own
bin/charlyand its own freshness-guard scope, a teammate editingcharly/*.goin its own worktree never trips another teammate’s bed run — there is no shared binary to freeze. The only freeze that applies is the WITHIN-worktree self-freeze: freeze your own worktree’scharly/*.gofor the duration of your own bed run (the per-tree freshness guard compares the invoked binary against the cwd’s sources at every heavy verb, mid-run), and keep one binary-build owner per worktree at a time. Full discipline:/charly-internals:agents“The charly binary in a multi-teammate / multi-worktree setup” + “Within-worktree self-freeze”.
B6 — cross-repo landing when a change is referenced via @github
Section titled “B6 — cross-repo landing when a change is referenced via @github”The resolver (EnsureRepoDownloaded) fetches a producer repo from the REMOTE at
the pinned ref, so a producer change on a local feat/ branch — or an OPEN,
unmerged PR — is invisible to a consumer’s R10. The producer PR must be MERGED
first. Staged landing:
- Develop producer (A) + consumer (B) on the same
feat/<slug>. - Land the producer FIRST: A’s own R10 PASS → open A’s PR → fresh
pr-validatorvalidates, merges, and tags Av<CalVer_A>— now an immutable, fetchable remote tag on A’s realmain. - Repoint the consumer:
charly box reconcilerewrites B’s@github.../A:...pins tov<CalVer_A>(see/charly-build:reconcile). - Authoritative consumer R10 against the real tag: B’s R10 now fetches A from
the pushed
v<CalVer_A>— verified against exactly what shipped. - Land the consumer: open B’s PR → fresh
pr-validatorvalidates, merges, tags. - New candy: a new candy has no standalone R10 — its gate is the consuming
image’s build. A lands a provisional
v<CalVer_A>(layer +go test/charly box generatesmoke); step 4 (B’s image R10 against that tag) is the real gate. On failure, fix A, land a new tag (immutable + accumulate — never move the old one), re-reconcile, re-run step 4.
Each repo gets ONE R10 against ITS final code; repos land producer→consumer. Multi-level chains (A→B→C) recurse the same way.
B6a — the documentation landing is the same chain
Section titled “B6a — the documentation landing is the same chain”This project publishes opencharly.ai from GENERATED trees, so a prose edit is a
producer→consumer landing exactly like a code one, and the same “producer must be
merged first” rule applies. /charly-build:docs owns the projection model; the
load-bearing part of it is that the two chains have DIFFERENT lengths — candy and
box prose is a ONE-hop projection read straight off each repo’s charly.yml,
while skill prose is a TWO-hop projection that charly docs generate reads from
the marketplace/ tree, never from candy/*/charly.yml.
The commands below are maintenance THIS REPOSITORY performs on itself. They are not steps an charly user runs, and they never belong on a reader-facing page.
- Edit the source, never a generated file — the candy’s
description:/plan:, or the owning candy’sskill:content. - For a skill edit, project and land
pluginsFIRST (charly marketplace generate, then thepluginsPR). Locally the docs generator reads the dirtymarketplace/tree, so a local regeneration picks the edit up immediately; every other reader gets it only once the gitlink advances. Advancing that gitlink WITHOUT regeneratingdocsin the same landing is precisely what leaves the published site stale, and only the drift gate will say so. Superproject-facing drift is EXPECTED while the projection PR is in flight — the superproject necessarily still pins the old gitlink until the projection merges — so that redness is not the tagging bar for the projection PR itself: its gate is thepluginstree, and the superproject catches up at step 5. - Regenerate and land
docs— a docs PR that bumps the charly pin indocs/.gitmodulesand carries the regenerated pages; the docs repo’s deploy workflow is the gate (it regenerates on every run and fails on any diff). Regeneration rewrites the generated trees WHOLESALE, so a mirror already behind cannot be brought forward selectively: every pending page lands with the next commit or none of them do. Budget for carrying someone else’s backlog when the mirror has drifted. - Re-pin the new
docsmerge incandy/docs-site/charly.yml;task docs:pinis the gate (it comparesDOCS_REFagainst the docs repo’s currentmainhead) and asserts more than one occurrence (/charly-tools:docs-siteowns the pin contract). This step edits candy CONFIG rather than prose, so its own gate is thecheck-docsbed — a documentation-only change class does NOT cover it, and the commit that carries it cannot claim thedocumentation reviewedtier. Positively: its gate ischarly box validatePLUScheck-docs— thedisposable: truebed that composes the changed entity, throughdocs-site-app— atfully tested and validated. Watch for it on a landing whose only “extra” is this re-pin: the step silently upgrades the WHOLE cutover’s gate, becauseDOCS_REFis emitted as an ENV line above the clone step, so re-pinning it changes the emitted Containerfile and therefore the image. The general rule the boundary rests on is in B2, “the derivation stops at the gitlink”. - Bump the superproject gitlinks for whichever submodules moved.
Why that order and not any other — the two-directional pin rule. A superproject gitlink and a mirror’s own pin are DIFFERENT relations, and conflating them is what produces an unpinnable mirror:
- Source-covers. The superproject pins a
pluginssha that must CONTAIN every source edit producing what the mirror shows. It is a coverage claim: “everything rendered downstream is generated from something at or before this sha.” - Mirror-reflects. The
docsmirror is a PROJECTION of one specific source state. It is an identity claim: “these pages are what that state renders to.”
The two shas are not the same and are not required to be — but the second is only meaningful relative to the first. A mirror leg that accumulates a SECOND merge before its superproject leg pins the first makes the mirror unpinnable by any state the superproject can reach: the mirror becomes a composite of projections from several submodule commits, and no single pin reproduces a composite.
Landing the mirror ahead is not the hazard — it is REQUIRED, because a superproject cannot pin a commit that does not exist yet. The hazard is the GAP, and specifically other legs’ projections landing inside it.
The mechanism, stated because a reader without it reconstructs the unqualified rule
and then finds it forbids the procedure below: several legs share ONE submodule
pointer. While your mirror merge sits unpinned, every other leg that lands projects
into the same tree. The superproject then has no sha that reproduces only your
change — the mirror is a composite, and the last leg to pin inherits every
projection beneath it. That is not hypothetical: it is how a four-cutover forced
union — charly#278 — came to carry four cutovers’ candy sources in one commit: the
check-verb resolver, the git-workflow landing lessons, the merge-tree guard, atop
the R4a sweep. None of those authors chose to couple their work; no intermediate
self-consistent superproject state existed for them to land against.
So: land each mirror leg immediately before its superproject leg, keeping at most one
outstanding merge per mirror — not one across all of them. B6 legitimately has
plugins and docs both outstanding between steps 2/3 and step 5; that is two
mirrors with one merge each, which is fine. What is not fine is two merges in ONE
mirror awaiting a single pin. Then nothing intervenes, each mirror contains only
your own projection, and the shas coincide naturally with no reconciliation to
perform.
Proof that this is a live invariant rather than an aspiration: regenerate at
main’s OWN pins and count the pages that move. Zero means every published page is
reproducible from the state the superproject currently points at. A non-zero count is
the composite above, and it names its own repair — the pages listed are exactly those
whose source landed out of order.
B7 — Multi-worktree landing + refresh (the canonical end-to-end)
Section titled “B7 — Multi-worktree landing + refresh (the canonical end-to-end)”When this project is driven from multiple git worktrees sharing one .git, only
ONE worktree can have main checked out at a time. The main tree stays on
main; every feature session works in its OWN worktree under
.claude/worktrees/<slug>/ — one worktree per session, created at session
start and removed after landing (B1 step 0 + B4 “Worktree hygiene”). Every
“land + update all worktrees” follows this EXACT ordered sequence. It composes
B1 (branch loop), B2 (per-repo order + pointer-bump safety), B4 (sync/prune).
0. Pre-flight (worktree safety). git worktree list → note which worktree
holds main. Pin ONE worktree for the whole edit→commit→push sequence; drive every
step with a literal absolute path git -C /abs/path …. NEVER a leading
cd+\-continued chain (it scopes every later command into the submodule) and
NEVER a shell variable for a path — shell variables do NOT persist between Bash
tool calls, so a WT=… set in an earlier call is EMPTY later and git -C "$WT/plugins" silently becomes git -C /plugins (this was a real failure). Verify:
git -C /abs rev-parse --show-toplevel == the path you edited AND git -C /abs status --short lists your edits.
1. Sync-before-start. git fetch origin --prune --tags; ff local main to
origin/main (B4).
2. Open the PRs in dependency order, same feat/<slug> in every repo (sdk when
touched → box submodules → plugins → superproject). Per-repo mechanics = B2 + B1
step 1; pointer-bump safety = B2 step 3.
Two proven additions:
- plugins docs commit at
documentation reviewed:git -C <LITERAL-abs-plugins> commit …. The literal path keeps repository selection explicit and lets the fresh validator inspect the plugins diff independently. Do NOT use a shell variable that may be unset or an in-command directory change. - box/
re-stamp (schema-HEAD bump): edit on the submodule’s own feat branch; gate =charly box validatestandalone (a version-stamp change has no build behavior — building proves nothing); commit, open PR, native auto-merge + tag-on-merge tags.
3. Land main via the PR — NEVER git push origin main (blocked) and NEVER git switch main in another worktree (git fatals “already used by worktree”). The
org-wide native auto-merge performs the server-side squash merge (it advances
origin/main remotely); then advance the LOCAL main ref where it lives:
git -C <main-wt> merge --ff-only origin/main. A local main now only ever
fast-forwards to what was merged remotely.
4. Tags: annotated only (git tag -a v<…> -m "<desc>" <merged-HEAD>), applied
by tag-on-merge on the merged main HEAD and pushed as refs/tags/… (allowed by
the pre-push-gate; the user token triggers the release-binary workflow). Verify git cat-file -t <tag> == tag AND git ls-remote --tags origin <tag> is non-empty.
5. Reconcile (when box submodules were re-stamped). Bump the superproject
GITLINKS +1 to the re-stamped box mains (a separate superproject PR; B2 step-3
safety) — do NOT bump the @github build pins: they lag deliberately, charly box reconcile reports “already reconciled”, and bumping them pulls multi-cutover
producer drift (a separate version-adoption cutover, NOT reconciliation).
6. Refresh EVERY worktree — PART of landing, NEVER a follow-up (R2). For each
worktree: the one on main → git -C <wt> merge --ff-only origin/main; each other
→ git -C <wt> checkout --detach origin/main; THEN refresh only already-initialized
submodules with git -C <wt> submodule update --recursive (no --init). Initialize
only the paths the next task needs: a root R10 worktree needs sdk, so
use git -C <wt> submodule update --init --recursive sdk; box-submodule work
initializes its own declared path. Never blanket-initialize every submodule merely to
refresh a worktree: it creates unnecessary per-worktree clone state. The Skill tool
serves skills from the MAIN worktree — a stale main worktree silently serves STALE SKILLS
to sessions, so refreshing it is mandatory. (A M <sub> in a worktree used only for
the ff-merge is this drift, not lost work.)
A disposable bed that builds from a submodule builds from the submodule’s
ON-DISK WORKING-TREE checkout, NOT from the committed gitlink alone. After a
CLEAN gitlink auto-merge (no conflict), git ls-tree HEAD <sub> can already
show the correct new pin while the submodule’s on-disk HEAD is still the OLD
commit — a gitlink merge does not itself check out the new submodule content;
that needs its own git -C <wt> submodule update --checkout <path> (or
--recursive over the initialized set), same as any other post-merge refresh
in this step. Skipping it means the bed silently builds STALE source even
though the committed pointer is correct.
Landing gotchas (each cost real time): git merge-base --is-ancestor A B ERRORS if B’s object isn’t
fetched (common for a sibling-worktree submodule) → git fetch first; cross-check
git ls-tree origin/main <sub> before concluding “DIVERGED”. A git grep -- <submodule-path> from the superproject is a FALSE ZERO (git grep does not cross a
gitlink) → git -C <sub> grep for the R5 sweep. The authoritative feat-branch head
SHA comes from git ls-remote origin refs/heads/<branch> — gh pr view --json headRefOid LAGS a fresh push and will post the status on a stale SHA. Deriving the
merge-time CalVer from a COMMIT’s recorded date (git show --date=format:'<fmt>' <sha>, git log -1 --format=%cd) uses that commit’s own author/committer TZ
offset, not UTC, and can mis-stamp the tag/changelog by hours — $VER always comes
from the LIVE clock at the moment of merge (date -u +%Y.%j.%H%M; the CalVer contract
lives in references/validator-and-calver.md, not in this file), never from a
commit’s stored timestamp. A metric or grep verification
command (a LOC count, a git grep sweep, a file-count claim) run from an
ambient, cd-inherited working directory silently measures the WRONG tree the
moment more than one worktree is in play — anchor every such command to an
explicit repo root (git -C /abs/path grep …, or a git rev-parse --show-toplevel cross-check first), never a bare relative command trusting
the shell’s current directory. A stronger, WRITE-side form of the same
footgun: a MUTATING command (anything that writes files or runs git submodule update as a side effect — task cue:gen is the canonical
offender) run against a stale, ambient cwd doesn’t just misreport, it
MUTATES the wrong tree. Failure mode: a fresh evaluator runs task cue:gen
with a persisted shell cwd that had drifted to the main session worktree —
the task’s own git submodule update chain rewound 5 submodule checkouts
there before the mistake was caught (fully restored,
disclosed). So every mutating task/command invocation in an isolated-worktree
workflow (a validator run, a teammate’s branch work, a spike) carries an
explicit cd <worktree> && anchor IN THE SAME compound command — never a
bare task cue:gen (or any command with submodule/file-write side effects)
trusting a cwd set by an earlier, unrelated step.