Add docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md covering the outer-repo + user/ submodule structure, when to bump the pin (cross-repo feature boundaries, not routine content), the reachable/push-first rules, and the worktree + per-worktree dev-server flow. Update CLAUDE.md's folder explanation and add a "Dual-repo submodule structure" section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.2 KiB
title, date, category, module, problem_type, component, severity, applies_when, tags
| title | date | category | module | problem_type | component | severity | applies_when | tags | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dual-repo submodule workflow — outer dev-env repo + user/ content submodule | 2026-07-04 | architecture-patterns | Repo structure — outer repo + user/ content repo | architecture_pattern | git | medium |
|
|
Dual-repo submodule workflow
Context
This project is two independent git repositories that happen to be nested:
- Outer repo (
intotheeast-com.git) — the Grav dev environment:tests/,scripts/,docs/,docker-compose.yml,Dockerfile,Makefile,CLAUDE.md. user/repo (intotheeast-com-content.git) — all site content and the theme:pages/,config/,accounts/,themes/. It has its own remote (the Gitea content mirror) and its own release cadence (make content-push→ webhook → production pull).
As of 2026-07-04, the outer repo tracks user/ as a proper git submodule (.gitmodules at the outer root, git dir absorbed into .git/modules/user). Before that it was an orphaned gitlink — a 160000 tree entry with no .gitmodules, so git had no URL to populate or update it. That broke worktrees (a fresh outer worktree got an empty user/) and offered no supported sync path.
Why a submodule (and not untracking)
Two options were weighed: make it a real submodule, or stop tracking user/ in the outer repo entirely (gitignore it, symlink the real checkout in).
The submodule was chosen deliberately, for one reason that outweighs its ceremony:
- Routine content churn is benign — day-to-day entries/stories change
user/constantly and never break the dev environment. Those changes do not need to be reflected in the outer repo. - Cross-repo features must be tracked together. A feature like the journal post-form touches both repos (a plugin + theme JS/CSS in
user/, and tests/docs in the outer repo). The outer repo pinning an exactuser/commit records "this dev-env state expects this content/theme state" — so checking out the outer feature also gets the matchinguser/code. That coupling is real and worth having. - It enables per-worktree
user/checkouts, which is what makes true parallel work across both repos possible (see below). This was a hard requirement.
The cost accepted: a persistent M user dirty signal (intrinsic to submodules under active development) and the possibility of gitlink merge conflicts between outer branches. Neither is removed by the submodule; they are the price of version pinning.
The pointer-bump convention
The outer repo's user gitlink stores an exact user/ commit SHA. When to bump it:
- Routine content changes → do not bump. Push content with
make content-pushand leave the outer pin where it is. A stale pin during normal content work is expected and harmless. - At the end of a cross-repo feature → bump once. When the feature's
user/work is finalized, update the outer pin to the finisheduser/commit, as the final step of the feature (its ownchore: bump user pointer to <sha>commit, or folded into the final integration commit).
Two rules keep the pin from dangling for other machines/clones:
- Pin a commit reachable from
user/'s publishedmain. Prefer the merge-to-main commit. Pinning a feature-branch tip is safe only if that exact commit survives ontomain(fast-forward / no-squash merge); a squashed-away tip becomes an orphaned SHA andgit submodule updatefails elsewhere. - Push
user/before the outer repo. The submodule golden rule: the superproject references a child SHA, so the child must already be pushed.make content-pushhandles theuser/push — just do it before pushing the outer branch.
Production is unaffected either way: prod pulls user/ directly via the content-remote webhook, independent of the outer repo's pin. The pin is dev-side coordination only.
Parallel work: worktree + its own dev server
The payoff. Because docker-compose.yml mounts ./user relative to the compose file, and a worktree is a full copy of the outer tree (compose file included), each worktree serves its own user/. Two worktrees = two independent sites, no gitlink collisions.
Set up a feature worktree off main:
# outer worktree on a new feature branch
git worktree add .worktrees/<feature> -b feat/<feature> main
cd .worktrees/<feature>
# populate user/ at the pinned SHA, then branch it for the cross-repo work
git submodule update --init user
git -C user checkout -b feat/<feature>
# its own dev server — separate project name + port from the main checkout's :8081
docker compose -p itte-<feature> up -d
.worktrees/ is kept out of git via .git/info/exclude (local, shared across worktrees — no committed .gitignore change needed).
Teardown
A submodule inside a linked worktree stores its git dir under .git/modules/user/worktrees/<name>, so removing the outer worktree needs a second cleanup step:
docker compose -p itte-<feature> down
cd "$(git rev-parse --show-toplevel)" # back to the main checkout
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree
git worktree remove .worktrees/<feature> # remove the outer worktree
git branch -d feat/<feature> # if merged
Landing a commit on main without disturbing the main checkout
When the main checkout is mid-work on another branch, add a commit to main through a throwaway worktree instead of git checkout main (which would yank branches out from under an open IDE):
git worktree add .worktrees/main-tmp main
git -C .worktrees/main-tmp cherry-pick <sha> # or edit + commit
git worktree remove .worktrees/main-tmp
Gotchas
M user/m useris normal. UppercaseM= the pin differs fromuser/HEAD (bump pending or intentional). Lowercasem= the submodule working tree is dirty (e.g. an uncommittedconfig/site.yamlused for local testing). Neither is an error.- Gitlink merge conflicts still happen. If two outer branches pin different
user/SHAs, merging them conflicts on theuserentry. Resolve by choosing the correct (usually newer, merged) SHA, thengit add user. - Worktrees need
submodule update --init. A fresh outer worktree has an emptyuser/until you run it — it is not automatic. - The submodule git dir was absorbed (
git submodule absorbgitdirs user) so all worktrees share.git/modules/user.user/.gitis now a gitfile (gitdir: ../.git/modules/user), not a directory.make content-push/content-pullstill operate onuser/normally. - Access requires the content remote (SSH over Tailscale). A machine that cannot reach it cannot
submodule update— but it could never cloneuser/anyway, so this is not a regression.