Claude Code's (and Copilot's) native plugin installer has zero awareness of .apm/ nesting -- it convention-scans only flat skills/, agents/, commands/, hooks.json at each plugin's root. Confirmed via strings on the installed claude binary and live installs of git@holocron/gitea@holocron/kyberforge@ holocron, all reporting Skills(0) Agents(0) Hooks(0) post ADR-0015's apm conversion. Root cause (apm_cli/core/plugin_manifest.py): apm's plugin.json compiler deliberately strips skills/agents/commands keys, assuming the host already auto-discovers those convention directories -- it has no model of .apm/ being host-visible at all. Separately, apm's own bundle exporter (apm_cli/bundle/plugin_exporter.py, behind `apm pack --format plugin`) implements the correct .apm/ -> flat mapping, but only ever targeted build/<name>-<version>/, a path nothing in marketplace.json's source: points at. scripts/sync-plugin-content.sh wraps that bundle exporter and copies its agents/, skills/, commands/, instructions/, extensions/, and merged hooks.json back into each plugin's own root as a second tracked compiled-output category -- same governance status as .claude-plugin/plugin.json: generated from .apm/, never hand-edited. tests/ subdirectories are excluded from the mirror (dev fixtures, not host-visible runtime content; several hardcode a relative repo-root walk-up sized for the .apm/-nested depth, which breaks when duplicated one level shallower). Applied for real across all 6 plugins and verified two ways: `claude plugin validate --strict` passes on every real plugin directory, and a live `claude --plugin-dir <path> -p "list skills/agents"` behavioral test confirms content is now actually discovered. Also, from the same issue #90 review round: - scripts/check-manifests.sh pointed at each plugin's root-level plugin.json (checking skills/hooks/mcpServers/agents pointer fields) -- that file was a stale near-duplicate of .claude-plugin/plugin.json nothing else read or wrote, now deleted across all 6 plugins. check-manifests.sh is rewritten to validate .claude-plugin/plugin.json instead, and drops the pointer-field checks entirely (nothing to check -- those fields are correctly absent by design). Content-presence drift is now check-plugin-content-sync's job, a new pre-push hook wired in .pre-commit-config.yaml. docs/adr/0017 records the root cause and decision in full, including two rejected alternatives (patching plugin.json's path fields directly -- apm's compiler strips them on every run; pointing marketplace.json at apm pack's build/ output -- a version-suffixed non-source directory nothing can install from without an extra build step). ADR-0015 and CONTEXT.md are updated to point at it. Refs: #90
7.6 KiB
name, description, metadata
| name | description | metadata | |||||
|---|---|---|---|---|---|---|---|
| git-submodules | Use when managing Git submodules: add dependencies as submodules, initialize and update nested repositories, sync URLs, inspect status (including detached HEAD and divergence), and safely remove submodules. Handles multi-repo projects with pinning, parallel operations, and recursive traversal. Use for both initial setup and ongoing maintenance workflows, even if the user doesn't explicitly say "submodule". Do not use for general git operations outside of submodule management. |
|
Concept
A submodule is a full Git repository embedded as a subdirectory inside a parent repository (the superproject). The superproject doesn't store the submodule's files — it stores a pointer to a specific commit SHA in the submodule's own history, and the two repos keep fully independent commit histories.
Two files govern a submodule, and they serve different audiences:
.gitmodules— version-controlled, shared with collaborators. Defines each submodule's name, path, and canonical URL..git/config— local only, populated bygit submodule init. This is where local URL overrides live (e.g. a private mirror) — they never propagate to other clones.
The submodule's own .git directory lives at .git/modules/<name>/ in the superproject, linked to the submodule's working tree via a .git pointer file. After git submodule update, the working tree normally ends up in detached HEAD state — see Gotchas.
Gotchas
- Detached HEAD by default.
git submodule updatechecks out a specific commit, not a branch. Work on a branch first, then update the pointer in the superproject. Commits made in detached state are invisible until pinned. - Two pushes required, in order. Always commit and push the submodule first, then update and push the superproject's pointer. The superproject only stores a commit SHA — if that SHA isn't reachable on the submodule's remote yet,
git submodule updatefails for anyone who pulls the superproject before the submodule push lands. --recursiveis not default. Most commands operate one level deep. Pass--recursiveexplicitly for nested submodules..git/modules/persists aftergit rm. Manual cleanup is needed:rm -rf .git/modules/<name>/.- Detached HEAD detection. Status prefix
+means the checked-out commit differs from the superproject's recorded commit — normal afterupdate --remote, but should be re-pinned before committing. - Relative URLs resolve against the remote, not the filesystem. A
../foo.gitentry in.gitmodulesis relative to the superproject's default remote URL. - Custom
updatecommands are security-gated. A.gitmodulesentry ofupdate = !some-commandis never copied to.git/configbygit submodule init— this stops a clone from silently executing arbitrary code.
Conventions
- Use
rtk gitfor parent-repo operations. Drop into the submodule directory only for submodule-specific git commands (committing/pushing inside the submodule itself) — mixing the two from the wrong working directory targets the wrong repo's history. - Check for a dirty submodule before committing the parent pointer. After adding or updating a submodule, run
git statusin both the parent and the submodule. A-dirtysuffix means the submodule has uncommitted local changes; committing the parent pointer now would pin a state no one else can reproduce, since those changes exist only in the local working tree.
Operations
- Clone a repo that has submodules:
rtk git clone --recurse-submodules <url>(one step, Git 2.13+) orrtk git clone <url>followed byrtk git submodule update --init --recursive. - Add a submodule:
rtk git submodule add <url> <path>(-b <branch>to track a branch instead of a pinned commit,--depth 1for a shallow clone,-fto force past a gitignored path or name conflict,--name <name>when the logical name should differ from the path). Stages a.gitmodulesentry and a gitlink — a commit is still required. - Initialize:
rtk git submodule init [<path>...]copies submodule URLs from.gitmodulesto.git/config. This is the point at which local URL overrides can be edited before fetching. Does not clone — useupdate(orupdate --initto run both in one step). - Update (clone + checkout):
rtk git submodule update --init --recursiveis the common case — checks out the recorded commit in detached HEAD. Add--remote --merge(or--remote --rebase) to track the branch tip instead,--jobs <n>for parallel clones,-fto discard local changes. Full flag table:references/submodules.md. - Inspect status:
rtk git submodule status --recursive(add--cachedto show SHAs in the superproject index instead of the working tree). Status prefixes:-not initialized,+diverged from the superproject's recorded commit,Umerge conflict. - Sync and rebind URLs:
rtk git submodule sync --recursiveafter an upstream URL rename propagates.gitmoduleschanges into.git/config.rtk git submodule set-url <path> <url>changes a URL directly;rtk git submodule set-branch -b <branch> <path>sets the tracking branch used byupdate --remote. - Override a submodule URL locally (private mirror): local-only, doesn't propagate to collaborators, and gets overwritten by the next
sync. Full steps:references/submodules.md. - Run a command across all submodules:
rtk git submodule foreach --recursive '<command>'. Shell variables available inside<command>($name,$sm_path,$displaypath,$sha1,$toplevel):references/submodules.md. - Deinit (unregister without removing):
rtk git submodule deinit <path>(--allfor every submodule,-fif local modifications are present) clears the.git/configsection and empties the working tree.deinitis not removal — the.gitmodulesentry and the gitlink in the superproject's index are untouched. - Safe removal (destructive; confirm before executing) — full three-step sequence including the manual
.git/modules/cleanup:references/submodules.md. - Move an embedded
.gitinto.git/modules/:rtk git submodule absorbgitdirs [<path>...]— needed when a submodule was created or copied without going throughgit submodule add. Details:references/submodules.md.
Configuration
.gitmodules (version-controlled, shared with collaborators):
| Key | Purpose |
|---|---|
submodule.<name>.path |
Working tree path |
submodule.<name>.url |
Remote URL |
submodule.<name>.branch |
Branch used by update --remote |
submodule.<name>.update |
Default update procedure |
submodule.<name>.shallow |
Recommend shallow clone |
.git/config (local only, populated by init):
| Key | Purpose |
|---|---|
submodule.<name>.url |
Local URL override |
submodule.<name>.update |
Local procedure override |
submodule.fetchJobs |
Default parallelism for update --jobs |
submodule.recurse |
Auto-recurse submodule updates on pull/push/etc. |
rtk git config submodule.recurse true # keep submodules pinned automatically after every pull
Agent output format
Return results as structured data:
operation: <clone|add|init|update|sync|set-url|set-branch|status|summary|absorbgitdirs|remove>
status: <success|error|partial>
message: <human-readable summary>
details:
- <submodule-path>: <state>
conflicts: [<submodule-path>, ...] # if any
next_step: <recovery action if applicable>
For errors, include the git command output and recommend recovery (e.g., git submodule deinit, force-update, or URL override).