Nested repos — one git repo living inside another
You have a self-contained sub-project (a plugin, a shared lib, a third-party
skill, a repo with its own GitHub remote) and it must ALSO live inside this
repo. There are four ways to wire that, and they are NOT interchangeable — the
sharp differences are worktree auto-presence, who owns the history, and
how you develop/update it. Two classic failures this skill exists to kill:
reaching for git submodule by reflex (→ an empty dir in every fresh
worktree), and assuming .worktreeinclude will carry a nested repo into a
worktree (it will NOT — see leaf 3). Pick from the tree, don't default.
Decision tree — first match wins
- Third-party code you'll freeze + slim into THIS trunk (drop heavy media,
curate a snapshot)? → vendor-and-commit. This leaf already has a skill:
use
vendoring-skills for the full procedure (placement, slimming,
provenance, the lint B3 allowlist). Don't re-derive it here.
- Must be present in every worktree with ZERO setup, and you mainly CONSUME
it from upstream (rarely edit it in place)? → subtree.
- You DEVELOP it in place as its own repo (push to its own remote), want it
in EVERY worktree, and the parent should track only a reviewed source ref? →
gitignore + materialize-hook (register it in
worktree-repos.json).
- Parent must pin an EXACT version and you bump deliberately, and a one-time
init step per worktree is acceptable? → submodule.
Discriminators at a glance:
| Option |
Rides into new worktree automatically? |
History owned by |
In-place dev |
| submodule |
No — empty until git submodule update --init |
sub-repo (parent pins a SHA) |
clean |
| subtree |
Yes (native tracked files) |
parent (squashed in) |
sync ceremony |
| gitignore + materialize-hook |
Yes (hook clones on EnterWorktree + SessionStart) |
sub-repo (parent ignores it) |
clean |
| vendor-and-commit |
Yes (native tracked files) |
parent (snapshot) |
re-vendor only |
The four options
submodule
Parent records a gitlink: .gitmodules (URL) + a pinned commit SHA. The sub-repo
stays fully independent (own remote, own history).
- Update:
cd <path> && git pull, then in the parent git add <path> && git commit to record the new SHA. Deliberate, never automatic — that pin is
the feature (reproducibility).
- Worktree gotcha:
git worktree add does NOT check out submodules (Claude
Code uses default git worktree logic per the live docs), so the path is EMPTY
in a fresh worktree until git submodule update --init --recursive. Storage is
shared via .git/modules, so the init is fast — but it IS a per-worktree step.
- Pick when: exact-version pinning matters and the init step is acceptable.
subtree
The sub-repo's files become REAL tracked files in the parent — no nested .git,
no gitlink.
- Update:
git subtree pull --prefix=<path> <url> <branch> --squash (and
git subtree push … to send parent-side edits upstream).
- Worktree: present natively everywhere, zero setup.
- Pick when: you consume from upstream and want frictionless worktree
presence; you accept subtree-command sync instead of plain
git push.
gitignore + materialize-hook ← the way to ride a NESTED REPO into worktrees
The sub-repo stays a LIVE nested repo on disk (its own .git), .gitignored so
it's out of the parent's history.
.worktreeinclude does NOT work for this. It copies gitignored files,
and git does not recurse into a nested-repo boundary, so a nested-repo dir
enumerates as nothing to copy. Verified 2026-06-20 (prediction 55b1735b miss —
brand-foundry never rode in, twice). Single gitignored files ride fine; a
nested repo never does. Do not reach for .worktreeinclude here.
- Instead, REGISTER it. At the repo root, add a
{path, remote, ref} entry
to worktree-repos.json, where ref is the reviewed full 40-character commit
SHA. hooks/materialize_worktree_repos.py (wired to SessionStart
- PostToolUse[EnterWorktree]) clones any missing entry into the worktree —
from the local primary checkout if present (fast, offline), else the remote,
checks out that exact detached commit, verifies it, then points
origin at the
remote. No-op in the primary checkout; never clobbers an existing dir; fails
open. A mutable local experiment may explicitly set development: true, but
that mode is not reproducible or distribution-safe and must not ship unnoticed.
- Update: develop/
git pull in place exactly as a standalone repo.
- Worktree: cloned in automatically on
EnterWorktree (verified end-to-end
2026-06-20 — a real EnterWorktree auto-fired the hook and cloned brand-foundry
in) and on claude --worktree launch (SessionStart wiring; same engine, not
separately live-fired). Subagent isolation:worktree worktrees fire neither
trigger — minor edge; clone manually if a subagent needs it.
- Pick when: you develop it in place as its own repo, want it in EVERY
worktree, and the parent should track a reviewed source revision without
absorbing the sub-repo's files/history. This is what brand-foundry uses.
vendor-and-commit
Slim a clone and commit a snapshot into the trunk at <path>. → skill
vendoring-skills owns this procedure; go there. Native in every worktree;
updates = re-vendor (never hand-edit, or you lose upstream-trackability).
Worked example — brand-foundry (this harness, 2026-06-20)
skills/brand-foundry/ is its own repo (github.com/GhostlyGawd/brand-foundry),
developed in place; no separate clone exists on disk. Goal: develop in place +
ride into every worktree, without the trunk owning its history. → tree node 3:
gitignore skills/brand-foundry/ (keeps it out of the trunk) + register its
remote and reviewed commit in worktree-repos.json; the materialize hook clones
and verifies that commit in each worktree. The FIRST attempt used
.worktreeinclude and failed — it can't carry a nested-repo
dir (prediction 55b1735b miss). Submodule was rejected (empty in fresh worktrees
- trunk-coupling); subtree was rejected (would break in-place own-repo dev).
Packaging a MULTI-skill distributable — use the plugin format
The decision tree picks the git-NESTING mechanism; this picks the PACKAGING when
the sub-project is a bundle of several skills (plus its own commands/agents) meant
to be distributed and invoked as a unit. Package it as a Claude Code plugin,
not as skills hand-nested under one skills/<name>/ dir:
- Give it a
.claude-plugin/plugin.json manifest (name is the only field the
validator REQUIRES; it only WARNS on missing version/description/author, so
add those for a release-quality plugin). Put its skills at
<plugin>/skills/<skill>/SKILL.md, and use ${CLAUDE_PLUGIN_ROOT} for any path a
component references inside itself. Validate with claude plugin validate <path>.
- A plugin's skills are invoked namespaced as
<plugin>:<skill> (e.g.
prospector:validate). The namespacing is mandatory and isolating — a plugin
skill is NOT merged into the bare top-level skill namespace. (Verified against
the Claude Code plugins reference, 2026-06-23.)
- To ALSO develop the plugin in place as its own repo and ride it into every
worktree, combine this with decision-tree node 3 (gitignore + materialize-hook
via
worktree-repos.json) — packaging and nesting are orthogonal choices.
(session 16494681, 2026-06-20: placing the 5-skill prospector bundle needed a
dedicated claude-code-guide agent to recover the plugin spec from scratch, because
no skill covered multi-skill packaging.)
Rules
- Name the discriminator out loud before picking: worktree auto-presence?,
who owns history?, develop-in-place or consume? — those decide it.
- Don't reach for submodule just because "repo inside a repo" sounds like one;
it's the only option that does NOT auto-populate a worktree.
- Never use
.worktreeinclude to carry a NESTED REPO — it's for gitignored
files only. Use the registry + materialize hook.
- This skill is the chooser. Mechanics live elsewhere:
vendoring-skills
(vendor-and-commit), worktree (.worktreeinclude for gitignored files), and
worktree-repos.json + hooks/materialize_worktree_repos.py (nested repos).
1---2name: nested-repos3description: Nested repos — one git repo living inside another4---56# Nested repos — one git repo living inside another78You have a self-contained sub-project (a plugin, a shared lib, a third-party9skill, a repo with its own GitHub remote) and it must ALSO live inside this10repo. There are four ways to wire that, and they are NOT interchangeable — the11sharp differences are **worktree auto-presence**, **who owns the history**, and12**how you develop/update it**. Two classic failures this skill exists to kill:13reaching for `git submodule` by reflex (→ an *empty* dir in every fresh14worktree), and assuming `.worktreeinclude` will carry a nested repo into a15worktree (it will NOT — see leaf 3). Pick from the tree, don't default.1617## Decision tree — first match wins18191. **Third-party code you'll freeze + slim into THIS trunk** (drop heavy media,20 curate a snapshot)? → **vendor-and-commit**. This leaf already has a skill:21 use `vendoring-skills` for the full procedure (placement, slimming,22 provenance, the lint B3 allowlist). Don't re-derive it here.232. **Must be present in every worktree with ZERO setup, and you mainly CONSUME24 it from upstream** (rarely edit it in place)? → **subtree**.253. **You DEVELOP it in place as its own repo** (push to its own remote), want it26 in EVERY worktree, and the parent should track only a reviewed source ref? →27 **gitignore + materialize-hook** (register it in `worktree-repos.json`).284. **Parent must pin an EXACT version and you bump deliberately**, and a one-time29 init step per worktree is acceptable? → **submodule**.3031Discriminators at a glance:3233| Option | Rides into new worktree automatically? | History owned by | In-place dev |34|---|---|---|---|35| submodule | **No** — empty until `git submodule update --init` | sub-repo (parent pins a SHA) | clean |36| subtree | Yes (native tracked files) | parent (squashed in) | sync ceremony |37| gitignore + materialize-hook | Yes (hook clones on EnterWorktree + SessionStart) | sub-repo (parent ignores it) | clean |38| vendor-and-commit | Yes (native tracked files) | parent (snapshot) | re-vendor only |3940## The four options4142### submodule43Parent records a gitlink: `.gitmodules` (URL) + a pinned commit SHA. The sub-repo44stays fully independent (own remote, own history).45- **Update**: `cd <path> && git pull`, then in the parent `git add <path> &&46 git commit` to record the new SHA. Deliberate, never automatic — that pin is47 the feature (reproducibility).48- **Worktree gotcha**: `git worktree add` does NOT check out submodules (Claude49 Code uses default git worktree logic per the live docs), so the path is EMPTY50 in a fresh worktree until `git submodule update --init --recursive`. Storage is51 shared via `.git/modules`, so the init is fast — but it IS a per-worktree step.52- **Pick when**: exact-version pinning matters and the init step is acceptable.5354### subtree55The sub-repo's files become REAL tracked files in the parent — no nested `.git`,56no gitlink.57- **Update**: `git subtree pull --prefix=<path> <url> <branch> --squash` (and58 `git subtree push …` to send parent-side edits upstream).59- **Worktree**: present natively everywhere, zero setup.60- **Pick when**: you consume from upstream and want frictionless worktree61 presence; you accept subtree-command sync instead of plain `git push`.6263### gitignore + materialize-hook ← the way to ride a NESTED REPO into worktrees64The sub-repo stays a LIVE nested repo on disk (its own `.git`), `.gitignore`d so65it's out of the parent's history.66- **`.worktreeinclude` does NOT work for this.** It copies gitignored *files*,67 and git does not recurse into a nested-repo boundary, so a nested-repo dir68 enumerates as nothing to copy. Verified 2026-06-20 (prediction 55b1735b miss —69 brand-foundry never rode in, twice). Single gitignored *files* ride fine; a70 nested repo never does. Do not reach for `.worktreeinclude` here.71- **Instead, REGISTER it.** At the repo root, add a `{path, remote, ref}` entry72 to `worktree-repos.json`, where `ref` is the reviewed full 40-character commit73 SHA. `hooks/materialize_worktree_repos.py` (wired to SessionStart74 + PostToolUse[EnterWorktree]) clones any missing entry into the worktree —75 from the local primary checkout if present (fast, offline), else the remote,76 checks out that exact detached commit, verifies it, then points `origin` at the77 remote. No-op in the primary checkout; never clobbers an existing dir; fails78 open. A mutable local experiment may explicitly set `development: true`, but79 that mode is not reproducible or distribution-safe and must not ship unnoticed.80- **Update**: develop/`git pull` in place exactly as a standalone repo.81- **Worktree**: cloned in automatically on `EnterWorktree` (verified end-to-end82 2026-06-20 — a real EnterWorktree auto-fired the hook and cloned brand-foundry83 in) and on `claude --worktree` launch (SessionStart wiring; same engine, not84 separately live-fired). Subagent `isolation:worktree` worktrees fire neither85 trigger — minor edge; clone manually if a subagent needs it.86- **Pick when**: you develop it in place as its own repo, want it in EVERY87 worktree, and the parent should track a reviewed source revision without88 absorbing the sub-repo's files/history. This is what brand-foundry uses.8990### vendor-and-commit91Slim a clone and commit a snapshot into the trunk at `<path>`. → **skill92`vendoring-skills`** owns this procedure; go there. Native in every worktree;93updates = re-vendor (never hand-edit, or you lose upstream-trackability).9495## Worked example — brand-foundry (this harness, 2026-06-20)96`skills/brand-foundry/` is its own repo (`github.com/GhostlyGawd/brand-foundry`),97developed in place; no separate clone exists on disk. Goal: develop in place +98ride into every worktree, without the trunk owning its history. → tree node **3**:99gitignore `skills/brand-foundry/` (keeps it out of the trunk) + register its100remote and reviewed commit in `worktree-repos.json`; the materialize hook clones101and verifies that commit in each worktree. The FIRST attempt used102`.worktreeinclude` and failed — it can't carry a nested-repo103dir (prediction 55b1735b miss). Submodule was rejected (empty in fresh worktrees104+ trunk-coupling); subtree was rejected (would break in-place own-repo dev).105106## Packaging a MULTI-skill distributable — use the plugin format107108The decision tree picks the git-NESTING mechanism; this picks the PACKAGING when109the sub-project is a bundle of several skills (plus its own commands/agents) meant110to be distributed and invoked as a unit. Package it as a Claude Code **plugin**,111not as skills hand-nested under one `skills/<name>/` dir:112113- Give it a `.claude-plugin/plugin.json` manifest (`name` is the only field the114 validator REQUIRES; it only WARNS on missing `version`/`description`/`author`, so115 add those for a release-quality plugin). Put its skills at116 `<plugin>/skills/<skill>/SKILL.md`, and use `${CLAUDE_PLUGIN_ROOT}` for any path a117 component references inside itself. Validate with `claude plugin validate <path>`.118- A plugin's skills are invoked **namespaced** as `<plugin>:<skill>` (e.g.119 `prospector:validate`). The namespacing is mandatory and isolating — a plugin120 skill is NOT merged into the bare top-level skill namespace. (Verified against121 the Claude Code plugins reference, 2026-06-23.)122- To ALSO develop the plugin in place as its own repo and ride it into every123 worktree, combine this with decision-tree node 3 (gitignore + materialize-hook124 via `worktree-repos.json`) — packaging and nesting are orthogonal choices.125126(session 16494681, 2026-06-20: placing the 5-skill `prospector` bundle needed a127dedicated claude-code-guide agent to recover the plugin spec from scratch, because128no skill covered multi-skill packaging.)129130## Rules131- Name the discriminator out loud before picking: *worktree auto-presence?*,132 *who owns history?*, *develop-in-place or consume?* — those decide it.133- Don't reach for submodule just because "repo inside a repo" sounds like one;134 it's the only option that does NOT auto-populate a worktree.135- Never use `.worktreeinclude` to carry a NESTED REPO — it's for gitignored136 *files* only. Use the registry + materialize hook.137- This skill is the chooser. Mechanics live elsewhere: `vendoring-skills`138 (vendor-and-commit), `worktree` (`.worktreeinclude` for gitignored files), and139 `worktree-repos.json` + `hooks/materialize_worktree_repos.py` (nested repos).