Git Submodule Extension

CHANGES with submodules is a Source Control view for workspaces with Git submodules. It shows every repository and nested submodule in one tree, with the groups, labels and settings of the built-in Changes view.
- Nested repositories: each submodule sits under its parent. A gitlink change expands into Adopted Changes, the files changed by the commits the pointer moved over.
- Everyday Git: commit, sync, pull with submodules, stage, unstage, discard, open diffs and copy paths, from row toolbars and a native context menu that works on a multi-row selection.
- Commit messages: the sparkle writes a submodule update message from the pointer diffs, and can use Cursor, Claude Code or GitLens for the rest.
- Branch restore: when a checkout, pull or discard leaves a submodule detached at its gitlink commit, the submodule is switched back to its branch. Restore is fail-closed: it never switches a submodule that is on a branch, never touches a dirty checkout, never drops local commits and never fetches (see Security guarantees).
Publisher: qjohn. Extension ID: qjohn.git-submodule-extension.

Why
Monorepos and deployment layouts with nested submodules are hard to read in the flat built-in Changes panel. This extension adds CHANGES with submodules — same everyday Git vocabulary, but repositories nest under their gitlink parent and pointer moves show Adopted Changes (inner commit diffs) inline.
Requirements
- VS Code
^1.85.0 (or a compatible fork such as Cursor)
- Built-in Git extension (
vscode.git)
- A Git binary on the host (path from
vscode.git)
Install
What it shows
The built-in Git Changes panel cannot be replaced or hidden through a stable API — hide it manually from the SCM view title menu if you want this pane to be the daily driver.
This extension contributes an SCM webview named CHANGES with submodules that lists:
- workspace folders as sibling repositories (including multi-root workspaces), each with a clickable local branch label (
main* unstaged, + staged, ! merge) and an always-visible toolbar (Commit, Sync or Publish, Refresh) whose icons animate while their action runs; Checkout Branch, Copy Branch Name, Fetch, Pull and Pull (Recurse Submodules) sit in the row context menu
- Merge Changes, Staged Changes, Changes, and (when
git.untrackedChanges is separate) Untracked Changes, using built-in names, pill-shaped file counts, file icons, and M/A/D/R/U decorations; folders use a dirty dot; gitlink rows use S
- empty groups according to the same built-in settings (
git.alwaysShowStagedChangesResourceGroup, git.untrackedChanges)
- gitlink pointer diffs nested under the matching Staged Changes / Changes row as Adopted Changes (
HEAD → index staged, index → checkout unstaged), always shown with a pill file count (including 0) and the inner commit file list. The gitlink row shows S and a gray commit → branch (or commit → commit) label. View as List / View as Tree applies to those inner files as well; layout also respects scm.defaultViewMode and scm.compactFolders when set in user settings
- nested submodules under their immediate gitlink parent, each with the same group layout
- collapsed repository names tinted with
gitDecoration.submoduleResourceForeground when a descendant gitlink or child checkout has changes
- file rows that open
vscode.diff (or vscode.changes for Open All)
- tree layout by default (reads
scm.defaultViewMode when set); the view title toggles View as List / View as Tree for this workspace session without writing workspace or user settings
The pane is a WebviewView (not a second SCM view). It renders the same AdoptedTreeNode model as HTML: 22px rows, 8px indent, status letters, count pills, and @vscode/codicons. Row toolbar and context actions call the existing gitSubmodule.* commands. Expanded dirty repositories (own staged/unstaged/untracked files) show the commit textarea (repository.inputBoxValue), Generate Commit Message sparkle, and Commit button before their groups; collapsing the repository hides that chrome with its subtree while preserving the draft. The sparkle builds a deterministic submodule chore from pointer diffs: nested gitlink updates appear indented under the parent submodule commit that moved them, each commit line includes a short SHA, and when the tree has exactly one leaf commit the subject becomes chore: update <submodule>: <leaf subject>. Staging is tracked only for preview range selection (HEAD → index vs index → checkout) and never appears in the generated commit message. When several leaf commits need summarizing, the AI commit-message provider (see Git Submodule: Generate Commit Message) may supply the subject; without AI the fallback is chore: update submodules. Without pointer diffs the sparkle only runs that AI path (or reports nothing to generate). Expand state is kept while the view is hidden (retainContextWhenHidden).
Built-in labels, menu slots, and intentional deviations (webview vs SourceControl, no proposed SCM API, gitSubmodule.* command IDs, no file-icon theme / view/item/context) are documented in docs/builtin-git-parity.md.
Settings
| Setting |
Default |
Meaning |
gitSubmodule.restore.enabled |
true |
After parent Git checkout/commit/state-change events, run fail-closed branch restore. A state change reported only by a submodule (for example a checkout that detached it at its pin) runs the restore of its parent too. |
gitSubmodule.restore.debounceMs |
250 |
Coalesce bursty Git events before a restore pass (0–10000). |
gitSubmodule.commitMessage.provider |
auto |
AI provider for Generate Commit Message: auto, cursor, claude, or gitlens. |
gitSubmodule.commitMessage.claudeModel |
haiku |
Model passed to the Claude Code CLI (--model). |
The generated UI fixture workspace sets gitSubmodule.restore.enabled to false so detached/dirty scenarios stay visible. Product default remains auto-safe on.
Commands
- Git Submodule: Refresh — reload the tree
- Git Submodule: Retry Branch Restore — explicit retry (never fetches)
- Git Submodule: Fetch Submodule Remote —
git fetch origin <branch> after a modal confirmation; never runs automatically
- Git Submodule: Pull (Recurse Submodules) —
git pull --recurse-submodules on the repository row; pulls the parent and updates the submodules its incoming gitlinks point at
- Git Submodule: Generate Commit Message — fills the commit draft of a repository with an AI message; never stages or commits. Takes a repository
Uri, a SourceControl, or a path (otherwise a repository picker), so it also works from keybindings and other extensions. In VS Code it appears in the built-in Source Control title when Claude Code or GitLens is the provider
- Open Changes / Open File / Open All Changes — tree item actions
AI commit-message providers
Generate Commit Message and the textarea sparkle use the first available provider that can target the requested repository (gitSubmodule.commitMessage.provider: auto):
- Cursor AI —
cursor.generateGitCommitMessage with the repository root Uri
- Claude Code CLI — the
claude binary bundled with the Claude Code extension (resources/native-binary) or a native claude on PATH. It runs headless (claude -p --tools "" --strict-mcp-config --setting-sources "" --no-session-persistence) from the temp folder, so no tools, hooks, MCP servers, or CLAUDE.md load. The staged diff (or, with nothing staged, the working-tree diff plus untracked file names) and the last 10 commit subjects go to stdin; the diff is capped at 60 000 characters. It uses your Claude Code login, and the diff is sent to Anthropic
- GitLens AI —
gitlens.ai.generateCommitMessage with repoPath; uses the provider configured in GitLens
- Copilot / built-in —
git.generateCommitMessage, github.copilot.* only when a single repository is open, because they cannot target one
A value other than auto uses only that provider.
Stage, unstage, discard, commit, refresh, publish, and the push half of Sync use the owning repository's public vscode.git API handle. Pull (Recurse Submodules) and the pull half of Sync shell out to git pull --recurse-submodules, because the vscode.git API has no --recurse-submodules option; they need an upstream, allow up to 10 minutes for the submodule fetches, and re-read the repository afterwards. Destructive discard and conflict staging require confirmation. Generate Submodule Chore (the textarea sparkle) only prepares an editable commit message; it never stages or commits.
Blocked restore cases appear on the submodule row, in the Git Submodule output channel, and on the status bar.
Output diagnostics
Open View → Output, then select Git Submodule. Every line uses one local wall-clock timestamp. Load lines retain [changes #N]: they identify one load generation and its trigger, bootstrap work, recursive discovery, tree build, serialization/post/render acknowledgement, total usable-tree time, result, and slowest phase. A separate adopted counts line reports background count hydration, including cache hits, Git diff calls, and the four-call concurrency bound. stale/cancelled means a newer generation replaced the load; it never represents a final tree.
User actions use a process-wide, monotonically increasing [action #N] id with monotonic durations. Each action has one start and exactly one completed, cancelled, or failed terminal line; concurrent actions remain attributable. Generate Message also reports its public AI command/provider result, submodule-chore preview count, and merge behavior without logging the generated text. Restore writes [blocked], [attached], [already-attached], or [skipped] for every child it evaluates, including an unchanged blocked reason on retry.
[15:57:12.184] [action [#14](https://github.com/sedlacl/git-submodule-extension/issues/14)] generate message started (repository: web-app)
[15:57:12.612] [action [#14](https://github.com/sedlacl/git-submodule-extension/issues/14)] submodule chore preview 28ms (pointer updates: 2; result: generated)
[15:57:12.640] [action [#14](https://github.com/sedlacl/git-submodule-extension/issues/14)] generate message AI 428ms (provider: git.generateCommitMessage; result: generated)
[15:57:12.642] [action [#14](https://github.com/sedlacl/git-submodule-extension/issues/14)] generate message completed 458ms (merge: AI subject + appended chore; pointer updates: 2)
[15:59:00.100] [action [#15](https://github.com/sedlacl/git-submodule-extension/issues/15)] commit cancelled 1.20s (reason: empty message; staged: 2; unstaged: 0; smart commit: no)
[12:20:09.326] [blocked] submodules/child: pinned commit is not an ancestor of origin/aflex/6.3
[12:20:09.374] [action [#11](https://github.com/sedlacl/git-submodule-extension/issues/11)] retry restore completed 1.36s (repositories: 1; blocked: 1)
Action coverage includes Generate Message/Submodule Chore, every stage/unstage/discard variant, commit, checkout, fetch, pull, pull recurse submodules, sync, publish, refresh, retry restore, and explicit submodule-remote fetch. Open diff/file commands are omitted because they are high-frequency navigation and would add noise.
Action diagnostics include only repository basenames, counts, operation outcomes, smart-commit state, and useful branch/remote names. They never include commit-message text, file contents, authentication data, full remote URLs, environment values, or absolute repository paths. Error text is flattened, length-limited, and redacts credentials, authorization headers, and URLs.
Security guarantees
Restore is fail-closed. Automatic work never does fetch, pull, push, commit, force, discard, or working-tree reset.
A child is attached (git switch -C <branch> <pin> plus upstream) only when all of these hold, re-checked immediately before the write:
- target branch comes from committed
HEAD:.gitmodules
- target SHA comes from the current parent HEAD gitlink
- child is an initialized Git worktree at the declared path
- child HEAD is detached at the pin; a child on any branch is skipped (
[skipped]) and never moved, and a child detached at another commit is blocked
- no Git operation is in progress
- working tree is clean
origin exists
- the pin object and
origin/<branch> exist locally
- the pin is an ancestor of
origin/<branch>
- the local branch has no unique commits versus
origin/<branch>
If any check fails, the child is left untouched and the reason is shown on the row, status bar, and as a [blocked] <path>: <reason> line in the Git Submodule output. Retry repeats the same checks and logs the blocked reason again even when it has not changed. Fetch is a separate, confirmed command.
Versioning
Bump package.json before every release (no git tag from npm by default):
npm run version:patch # 0.1.0 → 0.1.1
npm run version:minor # 0.1.0 → 0.2.0
npm run version:major # 0.1.0 → 1.0.0
npm run version:set -- 0.1.0
Add a CHANGELOG.md entry, then create an annotated tag vX.Y.Z (git tag -a) and git push origin HEAD --follow-tags. Publishing to the VS Code Marketplace and Open VSX runs in CI — see docs/publishing.md.
Development
npm install
npm run build
npm run typecheck
npm run lint
npm test
npm run test:extension-host
npm run package
Regenerate the README screenshot from the webview HTML renderer (no signed-in IDE session):
npm run capture-readme-screenshot
Isolated UI harness
npm run dev:ui starts esbuild watch and an Extension Development Host with an empty profile (--user-data-dir / --extensions-dir under .vscode-test/). Do not pass --disable-extensions: in Cursor it suppresses development extensions too and leaves the Changes view inactive. An empty --extensions-dir keeps user-installed marketplace extensions out while built-in Git and the development extension load normally.
npm run create-ui-fixture # generate fixtures/ui if missing
npm run dev:ui:reset # wipe and regenerate the fixture
npm run dev:ui # VS Code engine from @vscode/test-electron
npm run dev:cursor # same harness in Cursor (Electron app, not the CLI shim)
The harness opens fixtures/ui/ui-dev.code-workspace with local demo repositories only:
- a parent repo with two direct submodules and one nested checkout (staged/unstaged pointer, dirty, detached)
- an infra-style layout with repeated checkouts of the same sources under different path suffixes and branches
- plain top-level repos without submodules
Fixture remotes are local file:// paths. Nothing touches real application repositories.
To try auto-restore in the harness, set gitSubmodule.restore.enabled to true in that workspace. To inspect the same layout in Cursor, use the same empty profile flags and --extensionDevelopmentPath (without --disable-extensions).
Extension Development Host tests
npm run test:extension-host downloads the engine VS Code, opens the fixture workspace in an isolated profile, activates the extension, and checks contributed commands plus SCM refresh. It needs a desktop session that can launch VS Code.
Current limits
- The built-in Git SCM provider cannot be extended or replaced; hide its Changes panel manually. This extension is a sibling tree in the SCM panel. See
docs/builtin-git-parity.md.
- Restore never updates gitlinks, never initializes missing submodules, and never fetches automatically.
- Nested restore walks committed gitlinks only, with a depth cap of 32 and cycle guards.
- Adopted file diffs compare commits inside the child repository; they are not a substitute for
git submodule update.
- Real application workspaces should keep restore disabled until you have reviewed the tree (
gitSubmodule.restore.enabled: false).
Changelog
See CHANGELOG.md.
Upstream Git API
Public vscode.git types and change-group semantics are documented in
docs/vscode-git-api.md (microsoft/vscode tag 1.96.0,
file extensions/git/src/api/git.d.ts at d085816005ae61fc8f39b3720b3ec4594b35ecd0).
License
MIT — see LICENSE.
Repository
https://github.com/sedlacl/git-submodule-extension