Skip to content

Commit f6e3742

Browse files
NagyViktNagyVikt
andauthored
feat(gx): add gx submodule advance verb for monorepo pointer bumps (#558)
Adds a standalone command that, from a parent monorepo, advances each submodule pointer to the tracked branch's remote tip and commits the bump on the parent. Eliminates the manual `cd apps/storefront && git fetch && git checkout origin/main && cd ../.. && git add && git commit` ritual after submodule PRs merge. Usage: gx submodule advance [<path>] [--push] [--dry-run] [--branch <ref>] [--no-commit] [--target <path>] For each submodule in .gitmodules: - skips uninitialized submodules (would-init in dry-run, init in live) - skips submodules with local uncommitted changes (never overwrites) - fetches origin, resolves origin/<branch>, advances detached HEAD, stages the pointer bump - after processing, commits `chore: bump submodule pointer(s) (...)` when on a non-protected branch and the working tree is otherwise clean; --push publishes immediately Safety rails: - protected branches (e.g. main) block the auto-commit; pointer bumps are staged with a hint to run `gx branch start` first - working trees with unrelated edits block the auto-commit - dirty submodules are surfaced as skipped, never overwritten Smoke-tested against: - medusa-shops/compastor (dirty submodules) → both skipped-dirty - medusa-shops/lifted/LIFTEDV2 (the screenshot) → both would-advance with correct SHA ranges Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 835d155 commit f6e3742

4 files changed

Lines changed: 427 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-11
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# add-submodule-advance-verb (T1)
2+
3+
Branch: `agent/claude/add-submodule-advance-verb-2026-05-11-13-40`
4+
5+
## Why
6+
7+
Phase A made `gx setup` write `pull.recurseSubmodules=true` so `git pull` auto-updates submodule working dirs. But the pointer-bump step (telling the parent "your storefront should now point at the new SHA") still requires manual ritual: `cd apps/storefront && git fetch && git checkout origin/main && cd ../.. && git add apps/storefront && git commit`.
8+
9+
`gx submodule advance` is the verb that automates this.
10+
11+
Phase B name is `advance` (not `sync``git submodule sync` already means something else: syncing `.gitmodules` URLs into `.git/config`).
12+
13+
## Behavior
14+
15+
```
16+
gx submodule advance [<path>] [--push] [--dry-run] [--branch <ref>] [--no-commit] [--target <path>]
17+
```
18+
19+
For each submodule listed in `.gitmodules` (or only the one matching `<path>` if given):
20+
21+
1. If the submodule dir is uninitialized → would-init (dry-run) or `git submodule update --init <path>` (live).
22+
2. If the submodule has uncommitted changes → `skipped-dirty`. Never overwrites in-progress work.
23+
3. Fetch `origin` inside the submodule.
24+
4. Resolve `origin/<branch>` (from `.gitmodules` `branch =` field, default `main`, override with `--branch`).
25+
5. If pointer SHA == remote SHA → `unchanged`.
26+
6. Otherwise: dry-run → `would-advance`; live → checkout the new SHA inside the submodule, stage the pointer bump in the parent.
27+
7. After processing all targets: if any were bumped AND the parent is on a non-protected branch AND the working tree is otherwise clean → commit `chore: bump submodule pointer(s) (<paths>)` with a body listing `<short before>..<short after>` per submodule.
28+
8. `--push` adds a parent push after commit.
29+
30+
Safety rails:
31+
32+
- Skips dirty submodules — never overwrites local work.
33+
- Refuses to commit on a protected branch (e.g. `main`, `dev`): pointer bumps are staged but message tells user to `gx branch start` first or commit manually.
34+
- Refuses to commit when working tree has unrelated changes — only commits if the only modifications are the submodule pointers it just staged.
35+
36+
## Files
37+
38+
- `src/submodule/index.js` — new module: `parseGitmodules`, `advance`, `printAdvanceResult`.
39+
- `src/cli/main.js` — import `submoduleModule`, add `submodule(rawArgs)` function with `advance` subverb + help text, wire `command === 'submodule'` dispatch.
40+
41+
## Verification
42+
43+
Against `medusa-shops/compastor` (submodules dirty):
44+
45+
```
46+
- skipped-dirty apps/backend [main] (submodule has local uncommitted changes; refusing to overwrite)
47+
- skipped-dirty apps/storefront [main] (submodule has local uncommitted changes; refusing to overwrite)
48+
```
49+
50+
Against `medusa-shops/lifted/LIFTEDV2` (the screenshot — pointers behind remote):
51+
52+
```
53+
- would-advance apps/storefront 9a8f96ff..67d6c33b [origin/main]
54+
- would-advance apps/backend 89a12d0f..df91a450 [origin/main]
55+
```
56+
57+
Both behaviors match expected design.
58+
59+
## Follow-ups (Phase C, separate PR)
60+
61+
Workspace-aware `gx branch finish` — for the *agent* path, where an agent merges a submodule PR and the parent finish should auto-advance + commit the pointer. Phase B is the *user-facing* manual verb; Phase C is the lane-aware automation that calls similar plumbing on agent completion.
62+
63+
## Cleanup
64+
65+
- [x] Dry-run smoke test on compastor (refused-dirty) and LIFTEDV2 (would-advance).
66+
- [ ] Run: `gx branch finish --branch agent/claude/add-submodule-advance-verb-2026-05-11-13-40 --base main --via-pr --wait-for-merge --cleanup`
67+
- [ ] Record PR URL + `MERGED` state in the completion handoff.
68+
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).

src/cli/main.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const sandboxModule = require('../sandbox');
55
const toolchainModule = require('../toolchain');
66
const finishCommands = require('../finish');
77
const doctorModule = require('../doctor');
8+
const submoduleModule = require('../submodule');
89
const agentInspect = require('../agents/inspect');
910
const agentStatus = require('../agents/status');
1011
const agentCleanupSessions = require('../agents/cleanup-sessions');
@@ -3723,6 +3724,73 @@ function sync(rawArgs) {
37233724
return finishCommands.sync(rawArgs);
37243725
}
37253726

3727+
function submodule(rawArgs) {
3728+
const parsed = parseTargetFlag(rawArgs || [], process.cwd());
3729+
const [subcommand, ...rest] = parsed.args;
3730+
3731+
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
3732+
console.log(
3733+
`${TOOL_NAME} submodule commands:\n` +
3734+
` ${TOOL_NAME} submodule advance [<path>] [--push] [--dry-run] [--branch <ref>] [--no-commit] [--target <path>]\n\n` +
3735+
` advance — for each submodule listed in .gitmodules, fetch the tracked branch's\n` +
3736+
` remote tip, advance the parent pointer, and (when on a non-protected\n` +
3737+
` branch) commit the bump. Use --push to publish in one step.`,
3738+
);
3739+
return;
3740+
}
3741+
3742+
if (subcommand !== 'advance') {
3743+
throw new Error(`Unknown submodule subcommand: ${subcommand}. Try '${SHORT_TOOL_NAME} submodule help'.`);
3744+
}
3745+
3746+
let push = false;
3747+
let dryRun = false;
3748+
let commit = true;
3749+
let branchOverride = '';
3750+
let pathArg = '';
3751+
for (let i = 0; i < rest.length; i += 1) {
3752+
const arg = rest[i];
3753+
if (arg === '--push') {
3754+
push = true;
3755+
continue;
3756+
}
3757+
if (arg === '--dry-run' || arg === '-n') {
3758+
dryRun = true;
3759+
continue;
3760+
}
3761+
if (arg === '--no-commit') {
3762+
commit = false;
3763+
continue;
3764+
}
3765+
if (arg === '--branch' || arg === '-b') {
3766+
branchOverride = rest[i + 1] || '';
3767+
i += 1;
3768+
continue;
3769+
}
3770+
if (arg.startsWith('--branch=')) {
3771+
branchOverride = arg.slice('--branch='.length);
3772+
continue;
3773+
}
3774+
if (arg.startsWith('--')) {
3775+
throw new Error(`Unknown option for '${SHORT_TOOL_NAME} submodule advance': ${arg}`);
3776+
}
3777+
if (pathArg) {
3778+
throw new Error(`'${SHORT_TOOL_NAME} submodule advance' accepts at most one submodule path (got '${pathArg}' and '${arg}')`);
3779+
}
3780+
pathArg = arg;
3781+
}
3782+
3783+
const result = submoduleModule.advance({
3784+
target: parsed.target,
3785+
path: pathArg,
3786+
push,
3787+
dryRun,
3788+
commit,
3789+
branch: branchOverride,
3790+
});
3791+
submoduleModule.printAdvanceResult(result);
3792+
}
3793+
37263794
function cockpit(rawArgs) {
37273795
cockpitModule.openCockpit(rawArgs, {
37283796
resolveRepoRoot,
@@ -3887,6 +3955,7 @@ async function main() {
38873955
if (command === 'report') return report(rest);
38883956
if (command === 'protect') return protect(rest);
38893957
if (command === 'sync') return sync(rest);
3958+
if (command === 'submodule') return submodule(rest);
38903959
if (command === 'cleanup') return cleanup(rest);
38913960
if (command === 'release') return release(rest);
38923961

0 commit comments

Comments
 (0)