From baa852a5e6a03528c4a2014d3ae89a86633f57ca Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 11 May 2026 13:44:42 +0200 Subject: [PATCH] feat(gx): add `gx submodule advance` verb for monorepo pointer bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [] [--push] [--dry-run] [--branch ] [--no-commit] [--target ] 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/, 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 --- .../.openspec.yaml | 2 + .../notes.md | 68 +++++ src/cli/main.js | 69 +++++ src/submodule/index.js | 288 ++++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/.openspec.yaml create mode 100644 openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/notes.md create mode 100644 src/submodule/index.js diff --git a/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/.openspec.yaml b/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/.openspec.yaml new file mode 100644 index 0000000..81cd71f --- /dev/null +++ b/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/notes.md b/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/notes.md new file mode 100644 index 0000000..fe40698 --- /dev/null +++ b/openspec/changes/agent-claude-add-submodule-advance-verb-2026-05-11-13-40/notes.md @@ -0,0 +1,68 @@ +# add-submodule-advance-verb (T1) + +Branch: `agent/claude/add-submodule-advance-verb-2026-05-11-13-40` + +## Why + +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`. + +`gx submodule advance` is the verb that automates this. + +Phase B name is `advance` (not `sync` — `git submodule sync` already means something else: syncing `.gitmodules` URLs into `.git/config`). + +## Behavior + +``` +gx submodule advance [] [--push] [--dry-run] [--branch ] [--no-commit] [--target ] +``` + +For each submodule listed in `.gitmodules` (or only the one matching `` if given): + +1. If the submodule dir is uninitialized → would-init (dry-run) or `git submodule update --init ` (live). +2. If the submodule has uncommitted changes → `skipped-dirty`. Never overwrites in-progress work. +3. Fetch `origin` inside the submodule. +4. Resolve `origin/` (from `.gitmodules` `branch =` field, default `main`, override with `--branch`). +5. If pointer SHA == remote SHA → `unchanged`. +6. Otherwise: dry-run → `would-advance`; live → checkout the new SHA inside the submodule, stage the pointer bump in the parent. +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) ()` with a body listing `..` per submodule. +8. `--push` adds a parent push after commit. + +Safety rails: + +- Skips dirty submodules — never overwrites local work. +- 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. +- Refuses to commit when working tree has unrelated changes — only commits if the only modifications are the submodule pointers it just staged. + +## Files + +- `src/submodule/index.js` — new module: `parseGitmodules`, `advance`, `printAdvanceResult`. +- `src/cli/main.js` — import `submoduleModule`, add `submodule(rawArgs)` function with `advance` subverb + help text, wire `command === 'submodule'` dispatch. + +## Verification + +Against `medusa-shops/compastor` (submodules dirty): + +``` +- skipped-dirty apps/backend [main] (submodule has local uncommitted changes; refusing to overwrite) +- skipped-dirty apps/storefront [main] (submodule has local uncommitted changes; refusing to overwrite) +``` + +Against `medusa-shops/lifted/LIFTEDV2` (the screenshot — pointers behind remote): + +``` +- would-advance apps/storefront 9a8f96ff..67d6c33b [origin/main] +- would-advance apps/backend 89a12d0f..df91a450 [origin/main] +``` + +Both behaviors match expected design. + +## Follow-ups (Phase C, separate PR) + +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. + +## Cleanup + +- [x] Dry-run smoke test on compastor (refused-dirty) and LIFTEDV2 (would-advance). +- [ ] Run: `gx branch finish --branch agent/claude/add-submodule-advance-verb-2026-05-11-13-40 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/cli/main.js b/src/cli/main.js index 6d25e85..bf46c40 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -5,6 +5,7 @@ const sandboxModule = require('../sandbox'); const toolchainModule = require('../toolchain'); const finishCommands = require('../finish'); const doctorModule = require('../doctor'); +const submoduleModule = require('../submodule'); const agentInspect = require('../agents/inspect'); const agentStatus = require('../agents/status'); const agentCleanupSessions = require('../agents/cleanup-sessions'); @@ -3723,6 +3724,73 @@ function sync(rawArgs) { return finishCommands.sync(rawArgs); } +function submodule(rawArgs) { + const parsed = parseTargetFlag(rawArgs || [], process.cwd()); + const [subcommand, ...rest] = parsed.args; + + if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { + console.log( + `${TOOL_NAME} submodule commands:\n` + + ` ${TOOL_NAME} submodule advance [] [--push] [--dry-run] [--branch ] [--no-commit] [--target ]\n\n` + + ` advance — for each submodule listed in .gitmodules, fetch the tracked branch's\n` + + ` remote tip, advance the parent pointer, and (when on a non-protected\n` + + ` branch) commit the bump. Use --push to publish in one step.`, + ); + return; + } + + if (subcommand !== 'advance') { + throw new Error(`Unknown submodule subcommand: ${subcommand}. Try '${SHORT_TOOL_NAME} submodule help'.`); + } + + let push = false; + let dryRun = false; + let commit = true; + let branchOverride = ''; + let pathArg = ''; + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + if (arg === '--push') { + push = true; + continue; + } + if (arg === '--dry-run' || arg === '-n') { + dryRun = true; + continue; + } + if (arg === '--no-commit') { + commit = false; + continue; + } + if (arg === '--branch' || arg === '-b') { + branchOverride = rest[i + 1] || ''; + i += 1; + continue; + } + if (arg.startsWith('--branch=')) { + branchOverride = arg.slice('--branch='.length); + continue; + } + if (arg.startsWith('--')) { + throw new Error(`Unknown option for '${SHORT_TOOL_NAME} submodule advance': ${arg}`); + } + if (pathArg) { + throw new Error(`'${SHORT_TOOL_NAME} submodule advance' accepts at most one submodule path (got '${pathArg}' and '${arg}')`); + } + pathArg = arg; + } + + const result = submoduleModule.advance({ + target: parsed.target, + path: pathArg, + push, + dryRun, + commit, + branch: branchOverride, + }); + submoduleModule.printAdvanceResult(result); +} + function cockpit(rawArgs) { cockpitModule.openCockpit(rawArgs, { resolveRepoRoot, @@ -3887,6 +3955,7 @@ async function main() { if (command === 'report') return report(rest); if (command === 'protect') return protect(rest); if (command === 'sync') return sync(rest); + if (command === 'submodule') return submodule(rest); if (command === 'cleanup') return cleanup(rest); if (command === 'release') return release(rest); diff --git a/src/submodule/index.js b/src/submodule/index.js new file mode 100644 index 0000000..1ffc9b2 --- /dev/null +++ b/src/submodule/index.js @@ -0,0 +1,288 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const { run } = require('../core/runtime'); +const { TOOL_NAME, SHORT_TOOL_NAME } = require('../context'); +const { resolveRepoRoot, currentBranchName } = require('../git'); + +function gitOut(cwd, args, { allowFailure = false } = {}) { + const result = run('git', ['-C', cwd, ...args]); + if (!allowFailure && result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`); + } + return result; +} + +function parseGitmodules(repoRoot) { + const file = path.join(repoRoot, '.gitmodules'); + if (!fs.existsSync(file)) return []; + const text = fs.readFileSync(file, 'utf8'); + const entries = []; + let current = null; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const header = line.match(/^\[submodule\s+"([^"]+)"\]$/); + if (header) { + if (current) entries.push(current); + current = { name: header[1], path: '', url: '', branch: '' }; + continue; + } + if (!current) continue; + const kv = line.match(/^([a-zA-Z][a-zA-Z0-9._-]*)\s*=\s*(.*)$/); + if (!kv) continue; + const [, key, value] = kv; + if (key === 'path') current.path = value; + else if (key === 'url') current.url = value; + else if (key === 'branch') current.branch = value; + } + if (current) entries.push(current); + return entries.filter((entry) => entry.path); +} + +function readSubmoduleHeadSha(repoRoot, submodulePath) { + const result = gitOut(repoRoot, ['-C', submodulePath, 'rev-parse', 'HEAD'], { allowFailure: true }); + if (result.status !== 0) return ''; + return (result.stdout || '').trim(); +} + +function submoduleIsInitialized(repoRoot, submodulePath) { + const fullPath = path.join(repoRoot, submodulePath); + if (!fs.existsSync(fullPath)) return false; + const dotGit = path.join(fullPath, '.git'); + return fs.existsSync(dotGit); +} + +function ensureSubmoduleInitialized(repoRoot, submodulePath, { dryRun }) { + if (submoduleIsInitialized(repoRoot, submodulePath)) return { initialized: false }; + if (dryRun) return { initialized: false, wouldInit: true }; + gitOut(repoRoot, ['submodule', 'update', '--init', submodulePath]); + return { initialized: true }; +} + +function submoduleWorkingTreeDirty(repoRoot, submodulePath) { + const result = gitOut(repoRoot, ['-C', submodulePath, 'status', '--porcelain'], { allowFailure: true }); + if (result.status !== 0) return false; + return Boolean((result.stdout || '').trim()); +} + +function fetchSubmodule(repoRoot, submodulePath) { + return gitOut(repoRoot, ['-C', submodulePath, 'fetch', '--quiet', 'origin'], { allowFailure: true }); +} + +function resolveRemoteSha(repoRoot, submodulePath, ref) { + const candidate = ref || 'main'; + const remoteRef = candidate.startsWith('origin/') ? candidate : `origin/${candidate}`; + const result = gitOut(repoRoot, ['-C', submodulePath, 'rev-parse', remoteRef], { allowFailure: true }); + if (result.status !== 0) return { ok: false, ref: remoteRef, reason: (result.stderr || '').trim().split('\n')[0] }; + return { ok: true, ref: remoteRef, sha: (result.stdout || '').trim() }; +} + +function checkoutDetached(repoRoot, submodulePath, sha) { + return gitOut(repoRoot, ['-C', submodulePath, 'checkout', '--detach', sha]); +} + +function stageSubmoduleInParent(repoRoot, submodulePath) { + return gitOut(repoRoot, ['add', '--', submodulePath]); +} + +function repoIsClean(repoRoot) { + const result = gitOut(repoRoot, ['status', '--porcelain'], { allowFailure: true }); + if (result.status !== 0) return true; + return !(result.stdout || '').trim(); +} + +function isProtectedBranch(repoRoot) { + const branch = currentBranchName(repoRoot) || ''; + if (!branch) return false; + if (branch.startsWith('agent/')) return false; + return true; +} + +function commitPointerBumps(repoRoot, bumped) { + const paths = bumped.map((entry) => entry.path).join(', '); + const subject = `chore: bump submodule pointer${bumped.length === 1 ? '' : 's'} (${paths})`; + const bodyLines = bumped.map((entry) => `- ${entry.path}: ${entry.before.slice(0, 8)}..${entry.after.slice(0, 8)} (${entry.ref})`); + const fullMessage = `${subject}\n\n${bodyLines.join('\n')}\n`; + return gitOut(repoRoot, ['commit', '-m', fullMessage]); +} + +function advance(options) { + const repoRoot = resolveRepoRoot(options.target); + const entries = parseGitmodules(repoRoot); + if (entries.length === 0) { + return { + repoRoot, + operations: [], + committed: false, + pushed: false, + message: 'no submodules declared in .gitmodules', + }; + } + + const filter = options.path ? path.normalize(options.path).replace(/\/+$/, '') : ''; + const targets = filter + ? entries.filter((entry) => path.normalize(entry.path) === filter) + : entries; + if (filter && targets.length === 0) { + throw new Error(`submodule path not found in .gitmodules: ${options.path}`); + } + + const overrideBranch = options.branch || ''; + const dryRun = Boolean(options.dryRun); + const operations = []; + const bumped = []; + + for (const entry of targets) { + const initResult = ensureSubmoduleInitialized(repoRoot, entry.path, { dryRun }); + if (initResult.wouldInit) { + operations.push({ + path: entry.path, + status: 'would-init', + ref: overrideBranch || entry.branch || 'main', + note: 'submodule not initialized; would run `git submodule update --init`', + }); + continue; + } + + if (submoduleWorkingTreeDirty(repoRoot, entry.path)) { + operations.push({ + path: entry.path, + status: 'skipped-dirty', + ref: overrideBranch || entry.branch || 'main', + note: 'submodule has local uncommitted changes; refusing to overwrite', + }); + continue; + } + + const before = readSubmoduleHeadSha(repoRoot, entry.path); + fetchSubmodule(repoRoot, entry.path); + const remote = resolveRemoteSha(repoRoot, entry.path, overrideBranch || entry.branch); + if (!remote.ok) { + operations.push({ + path: entry.path, + status: 'failed', + ref: remote.ref, + note: `could not resolve ${remote.ref}: ${remote.reason || 'unknown error'}`, + }); + continue; + } + + if (before === remote.sha) { + operations.push({ + path: entry.path, + status: 'unchanged', + ref: remote.ref, + before, + after: remote.sha, + note: 'already at remote tip', + }); + continue; + } + + if (dryRun) { + operations.push({ + path: entry.path, + status: 'would-advance', + ref: remote.ref, + before, + after: remote.sha, + note: `${before.slice(0, 8)}..${remote.sha.slice(0, 8)}`, + }); + continue; + } + + checkoutDetached(repoRoot, entry.path, remote.sha); + stageSubmoduleInParent(repoRoot, entry.path); + operations.push({ + path: entry.path, + status: 'advanced', + ref: remote.ref, + before, + after: remote.sha, + note: `${before.slice(0, 8)}..${remote.sha.slice(0, 8)}`, + }); + bumped.push({ path: entry.path, before, after: remote.sha, ref: remote.ref }); + } + + if (dryRun || bumped.length === 0) { + return { repoRoot, operations, committed: false, pushed: false }; + } + + if (options.commit === false) { + return { + repoRoot, + operations, + committed: false, + pushed: false, + note: 'pointer bumps staged but not committed (--no-commit)', + }; + } + + if (!repoIsClean(repoRoot)) { + // We staged submodule entries; check that the working tree is otherwise clean. + const dirty = gitOut(repoRoot, ['status', '--porcelain'], { allowFailure: true }).stdout || ''; + const lines = dirty.split('\n').filter(Boolean); + const onlyOurStages = lines.every((line) => { + const status = line.slice(0, 2); + const file = line.slice(3); + return /M /.test(status) && bumped.some((entry) => entry.path === file); + }); + if (!onlyOurStages) { + return { + repoRoot, + operations, + committed: false, + pushed: false, + note: 'working tree has unrelated changes; pointer bumps staged but not committed', + }; + } + } + + if (isProtectedBranch(repoRoot)) { + return { + repoRoot, + operations, + committed: false, + pushed: false, + note: + `current branch '${currentBranchName(repoRoot)}' looks protected; pointer bumps staged but not committed. ` + + `Start a lane with '${SHORT_TOOL_NAME} branch start' or commit manually.`, + }; + } + + commitPointerBumps(repoRoot, bumped); + let pushed = false; + if (options.push) { + gitOut(repoRoot, ['push']); + pushed = true; + } + return { repoRoot, operations, committed: true, pushed }; +} + +function printAdvanceResult(result) { + console.log(`[${TOOL_NAME}] submodule advance: ${result.repoRoot}`); + if (result.operations.length === 0) { + console.log(` ${result.message || 'nothing to do.'}`); + return; + } + for (const op of result.operations) { + const before = op.before ? ` ${op.before.slice(0, 8)}` : ''; + const after = op.after ? `..${op.after.slice(0, 8)}` : ''; + const note = op.note ? ` (${op.note})` : ''; + console.log(` - ${op.status.padEnd(14)} ${op.path}${before}${after} [${op.ref}]${note}`); + } + if (result.committed) { + console.log(`[${TOOL_NAME}] Pointer bump committed${result.pushed ? ' and pushed' : ''}.`); + } else if (result.note) { + console.log(`[${TOOL_NAME}] ${result.note}`); + } +} + +module.exports = { + advance, + parseGitmodules, + printAdvanceResult, +};