@openvcs/git-plugin 0.1.0-nightly.20260406.4 → 0.1.0-nightly.20260412.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -23,12 +23,20 @@ through `@openvcs/sdk/runtime` delegates and exposes a single VCS backend id:
23
23
  exact `vcs.*` JSON-RPC method names consumed by the runtime.
24
24
  - Status reads use `git status --porcelain=1 --branch -z -uall` so file paths are
25
25
  NUL-delimited and not C-quoted.
26
+ - After porcelain parsing, the plugin cross-references `.gitmodules` paths so
27
+ tracked submodule entries are surfaced with a dedicated submodule status marker
28
+ for the client UI.
26
29
  - For rename and copy records, the porcelain format includes two NUL-terminated
27
30
  paths: the original/source path first, then the new/destination path. The
28
31
  plugin assigns `path` to the new path and `old_path` to the original path.
29
32
  - Network commands (`fetch`, `push`, `pull`) omit optional arguments (remote,
30
33
  refspec, branch) when not provided, allowing Git to use its defaults instead
31
34
  of receiving empty string arguments.
35
+ - Clone uses `git clone --recurse-submodules` so repositories arrive with
36
+ submodules initialized by default.
37
+ - The Repository menu submodule toolkit keeps pinned `git submodule update`
38
+ behavior separate from explicit `--remote` updates that follow the configured
39
+ branch in `.gitmodules`.
32
40
 
33
41
  ## State
34
42
 
package/README.md CHANGED
@@ -9,6 +9,9 @@ This directory contains the System Git VCS backend plugin used by OpenVCS.
9
9
  - The plugin can add top-level app menus and items through `@openvcs/sdk/runtime` helpers.
10
10
  - The plugin can open generic plugin-owned modals with the SDK `ModalBuilder` helper.
11
11
  - The Repository menu includes Git-only submodule management tooling.
12
+ - In the desktop client, the `Submodules` entry appears in `Repository` after a Git repository is open and the Git plugin runtime is active.
13
+ - Repository clone operations recurse into submodules by default.
14
+ - Repository status views tag tracked submodule paths distinctly so the client can render them as submodules.
12
15
  - Git operations are executed through the local `git` CLI.
13
16
  - The runtime uses a trust model (no per-capability permission prompts).
14
17
 
@@ -18,7 +21,7 @@ This directory contains the System Git VCS backend plugin used by OpenVCS.
18
21
  npm install
19
22
  ```
20
23
 
21
- - The SDK dependency is pinned to the `^0.2` range so it tracks the latest `0.2.x` releases.
24
+ - The SDK dependency tracks the `edge` tag so it always follows the latest SDK commit.
22
25
 
23
26
  ## Validate
24
27
 
@@ -41,6 +44,12 @@ npm run build
41
44
  npm test
42
45
  ```
43
46
 
47
+ Submodule workflow highlights:
48
+
49
+ - `clone_repo` uses `git clone --recurse-submodules`.
50
+ - Repository > Submodules supports add, sync, remove, pinned updates, and explicit remote-tracking updates.
51
+ - `Update Remote` follows the branch configured for each submodule in `.gitmodules`.
52
+
44
53
  ## Pack For Config Use
45
54
 
46
55
  ```bash
package/bin/git.js CHANGED
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { rmSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { pluginError } from '@openvcs/sdk/runtime';
7
- import { asString, buildFetchArgs, buildPullFfOnlyArgs, buildPushArgs, parseCommits, parseStatusOutput, } from './plugin-helpers.js';
7
+ import { applySubmoduleStatusHints, asString, buildFetchArgs, buildPullFfOnlyArgs, buildPushArgs, buildSubmoduleUpdateArgs, parseCommits, parseStatusOutput, } from './plugin-helpers.js';
8
8
  export class GitCommand {
9
9
  cwd;
10
10
  constructor(cwd) {
@@ -60,7 +60,7 @@ export class GitCommand {
60
60
  }
61
61
  status() {
62
62
  const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
63
- const parsed = parseStatusOutput(result.stdout);
63
+ const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
64
64
  return { ...parsed, exitCode: result.status };
65
65
  }
66
66
  currentBranch() {
@@ -217,9 +217,10 @@ export class GitCommand {
217
217
  const commits = parseCommits(result.stdout);
218
218
  return { commits, exitCode: result.status };
219
219
  }
220
- listSubmodules() {
220
+ /** Reads `.gitmodules` entries indexed by submodule name and path. */
221
+ readSubmoduleConfig() {
221
222
  const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
222
- const configByName = new Map();
223
+ const byName = new Map();
223
224
  if (configResult.status === 0) {
224
225
  for (const entry of configResult.stdout.split('\0')) {
225
226
  const trimmed = entry.trim();
@@ -234,22 +235,30 @@ export class GitCommand {
234
235
  if (!match)
235
236
  continue;
236
237
  const [, name, field] = match;
237
- const target = configByName.get(name) || { name };
238
+ const target = byName.get(name) || { name };
238
239
  if (field === 'path')
239
240
  target.path = value;
240
241
  if (field === 'url')
241
242
  target.url = value;
242
243
  if (field === 'branch')
243
244
  target.branch = value;
244
- configByName.set(name, target);
245
+ byName.set(name, target);
245
246
  }
246
247
  }
247
- const configByPath = new Map();
248
- for (const entry of configByName.values()) {
248
+ const byPath = new Map();
249
+ for (const entry of byName.values()) {
249
250
  if (entry.path) {
250
- configByPath.set(entry.path, entry);
251
+ byPath.set(entry.path, entry);
251
252
  }
252
253
  }
254
+ return { byName, byPath };
255
+ }
256
+ /** Returns known submodule paths from `.gitmodules`. */
257
+ listSubmodulePaths() {
258
+ return new Set(this.readSubmoduleConfig().byPath.keys());
259
+ }
260
+ listSubmodules() {
261
+ const { byPath: configByPath } = this.readSubmoduleConfig();
253
262
  const statusResult = this.run(['submodule', 'status', '--recursive']);
254
263
  if (statusResult.status !== 0) {
255
264
  return [];
@@ -295,10 +304,18 @@ export class GitCommand {
295
304
  return this.runChecked(args, 'git-submodule-add-failed');
296
305
  }
297
306
  updateSubmodule(path) {
298
- return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
307
+ return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
299
308
  }
300
309
  updateAllSubmodules() {
301
- return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
310
+ return this.runChecked(buildSubmoduleUpdateArgs({}), 'git-submodule-update-failed');
311
+ }
312
+ /** Updates one submodule from its configured branch recursively. */
313
+ updateSubmoduleRemote(path) {
314
+ return this.runChecked(buildSubmoduleUpdateArgs({ path, remote: true }), 'git-submodule-update-remote-failed');
315
+ }
316
+ /** Updates all submodules from their configured branches recursively. */
317
+ updateAllSubmodulesRemote() {
318
+ return this.runChecked(buildSubmoduleUpdateArgs({ remote: true }), 'git-submodule-update-remote-failed');
302
319
  }
303
320
  syncSubmodule(path) {
304
321
  return this.runChecked(['submodule', 'sync', '--recursive', '--', path], 'git-submodule-sync-failed');
@@ -52,6 +52,13 @@ export function buildPushArgs(params) {
52
52
  pushOptionalArg(args, params.refspec);
53
53
  return args;
54
54
  }
55
+ /** Builds `git clone` arguments with recursive submodule initialization enabled. */
56
+ export function buildCloneArgs(params) {
57
+ const args = ['clone', '--recurse-submodules'];
58
+ pushOptionalArg(args, params.url);
59
+ pushOptionalArg(args, params.dest);
60
+ return args;
61
+ }
55
62
  /** Builds `git pull --ff-only` arguments while omitting empty optional values. */
56
63
  export function buildPullFfOnlyArgs(params) {
57
64
  const args = ['pull', '--ff-only'];
@@ -59,6 +66,18 @@ export function buildPullFfOnlyArgs(params) {
59
66
  pushOptionalArg(args, params.branch);
60
67
  return args;
61
68
  }
69
+ /** Builds `git submodule update` arguments for pinned or remote-tracking updates. */
70
+ export function buildSubmoduleUpdateArgs(params) {
71
+ const args = ['submodule', 'update', '--init', '--recursive'];
72
+ if (params.remote === true) {
73
+ args.push('--remote');
74
+ }
75
+ const path = asTrimmedString(params.path);
76
+ if (path) {
77
+ args.push('--', path);
78
+ }
79
+ return args;
80
+ }
62
81
  /** Parses `git status --porcelain=1 --branch -z -uall` output into OpenVCS payloads. */
63
82
  export function parseStatusOutput(output) {
64
83
  const records = output.split('\0').filter(Boolean);
@@ -130,6 +149,31 @@ export function parseStatusOutput(output) {
130
149
  },
131
150
  };
132
151
  }
152
+ /** Applies submodule-specific status labels to known submodule paths. */
153
+ export function applySubmoduleStatusHints(parsed, submodulePaths) {
154
+ const knownPaths = new Set(Array.from(submodulePaths)
155
+ .map((entry) => asTrimmedString(entry))
156
+ .filter(Boolean));
157
+ if (knownPaths.size === 0) {
158
+ return parsed;
159
+ }
160
+ return {
161
+ ...parsed,
162
+ payload: {
163
+ ...parsed.payload,
164
+ files: parsed.payload.files.map((file) => {
165
+ const path = asTrimmedString(file.path);
166
+ if (!knownPaths.has(path)) {
167
+ return file;
168
+ }
169
+ return {
170
+ ...file,
171
+ status: 'S',
172
+ };
173
+ }),
174
+ },
175
+ };
176
+ }
133
177
  /** Parses `git log` output into commit entries expected by the host. */
134
178
  export function parseCommits(raw) {
135
179
  const records = raw
@@ -1,7 +1,7 @@
1
1
  // Copyright © 2025-2026 OpenVCS Contributors
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
  import { VcsDelegateBase, pluginError, } from '@openvcs/sdk/runtime';
4
- import { asNumber, asRecord, asString, asStringArray, asTrimmedString, } from './plugin-helpers.js';
4
+ import { asNumber, asRecord, asString, asStringArray, asTrimmedString, buildCloneArgs, } from './plugin-helpers.js';
5
5
  import { GitCommand } from './git.js';
6
6
  /** Implements the Git-backed `vcs.*` delegate surface for the SDK runtime. */
7
7
  export class GitVcsDelegates extends VcsDelegateBase {
@@ -45,7 +45,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
45
45
  throw pluginError('vcs-clone-invalid-args', 'url and dest are required');
46
46
  }
47
47
  const git = this.deps.createGitCommand(process.cwd());
48
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
48
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
49
49
  const lines = `${output.stdout}\n${output.stderr}`
50
50
  .split(/\r?\n/g)
51
51
  .map((line) => line.trim())
package/bin/submodules.js CHANGED
@@ -34,6 +34,12 @@ function buildSubmoduleRow(entry) {
34
34
  description,
35
35
  actions: [
36
36
  { type: 'button', id: 'submodules-update', content: 'Update', payload: { path: entry.path } },
37
+ {
38
+ type: 'button',
39
+ id: 'submodules-update-remote',
40
+ content: 'Update Remote',
41
+ payload: { path: entry.path },
42
+ },
37
43
  { type: 'button', id: 'submodules-sync', content: 'Sync', payload: { path: entry.path } },
38
44
  {
39
45
  type: 'button',
@@ -52,33 +58,51 @@ async function openSubmodulesModal() {
52
58
  console.log('Git submodules: building modal', { count: entries.length });
53
59
  const modal = new ModalBuilder('Manage Submodules')
54
60
  .text('Review, add, update, sync, and remove submodules without leaving Git.')
61
+ .text('Use Update Remote to follow each submodule branch configured in .gitmodules.')
55
62
  .separator()
56
- .input('url', 'Submodule URL', {
57
- kind: 'url',
58
- placeholder: 'https://example.com/repo.git',
59
- })
60
- .input('path', 'Submodule Path', {
61
- placeholder: 'libs/example',
62
- })
63
- .input('name', 'Submodule Name', {
64
- placeholder: 'example',
65
- })
66
- .input('branch', 'Branch (optional)', {
67
- placeholder: 'main',
68
- })
69
- .button('submodules-add', 'Add Submodule', {
70
- variant: 'primary',
71
- align: 'centered',
72
- })
73
- .button('repo-submodules', 'Refresh', {
74
- align: 'centered',
75
- })
76
- .button('submodules-update-all', 'Update All (Recursive)', {
77
- align: 'centered',
78
- })
79
- .button('submodules-sync-all', 'Sync All', {
80
- align: 'centered',
81
- })
63
+ .verticalBox([
64
+ {
65
+ type: 'input',
66
+ id: 'url',
67
+ label: 'Submodule URL',
68
+ kind: 'url',
69
+ placeholder: 'https://example.com/repo.git',
70
+ },
71
+ {
72
+ type: 'grid',
73
+ columns: 'minmax(0, 1fr) minmax(0, 1fr)',
74
+ gap: '.75rem',
75
+ content: [
76
+ {
77
+ type: 'input',
78
+ id: 'path',
79
+ label: 'Submodule Path',
80
+ placeholder: 'libs/example',
81
+ },
82
+ {
83
+ type: 'input',
84
+ id: 'name',
85
+ label: 'Submodule Name',
86
+ placeholder: 'example',
87
+ },
88
+ ],
89
+ },
90
+ {
91
+ type: 'input',
92
+ id: 'branch',
93
+ label: 'Branch (optional)',
94
+ placeholder: 'main',
95
+ },
96
+ ], { gap: '1rem' })
97
+ .horizontalBox([
98
+ { type: 'button', id: 'submodules-add', content: 'Add Submodule', variant: 'primary' },
99
+ { type: 'button', id: 'repo-submodules', content: 'Refresh' },
100
+ ], { gap: '.5rem', align: 'centered', wrap: true })
101
+ .horizontalBox([
102
+ { type: 'button', id: 'submodules-update-all', content: 'Update All (Recursive)' },
103
+ { type: 'button', id: 'submodules-update-all-remote', content: 'Update All From Branches' },
104
+ { type: 'button', id: 'submodules-sync-all', content: 'Sync All' },
105
+ ], { gap: '.5rem', align: 'centered', wrap: true })
82
106
  .separator()
83
107
  .list('submodules', {
84
108
  label: 'Submodules',
@@ -95,14 +119,16 @@ async function openRemoveConfirmationModal(path, name) {
95
119
  console.log('Git submodules: building remove confirmation', { path: submodulePath, name });
96
120
  const modal = new ModalBuilder('Confirm Submodule Removal')
97
121
  .text(`Remove the submodule at ${submodulePath}? This will deinitialize the submodule, remove it from the index, and delete its working tree entry.`)
98
- .button('repo-submodules', 'Back', {
99
- align: 'centered',
100
- })
101
- .button('submodules-remove-confirm', 'Remove Submodule', {
102
- variant: 'danger',
103
- align: 'centered',
104
- payload: { path: submodulePath, name: String(name || '').trim() || undefined },
105
- });
122
+ .horizontalBox([
123
+ { type: 'button', id: 'repo-submodules', content: 'Back' },
124
+ {
125
+ type: 'button',
126
+ id: 'submodules-remove-confirm',
127
+ content: 'Remove Submodule',
128
+ variant: 'danger',
129
+ payload: { path: submodulePath, name: String(name || '').trim() || undefined },
130
+ },
131
+ ], { gap: '.5rem', align: 'centered', wrap: true });
106
132
  return modal.open();
107
133
  }
108
134
  /** Removes one submodule and refreshes the toolkit modal. */
@@ -136,6 +162,15 @@ async function updateSubmodule(payload) {
136
162
  git.updateSubmodule(path);
137
163
  await openSubmodulesModal();
138
164
  }
165
+ /** Updates one submodule from its configured branch and refreshes the toolkit modal. */
166
+ async function updateSubmoduleRemote(payload) {
167
+ const path = payloadString(payload, 'path');
168
+ if (!path)
169
+ return;
170
+ const git = createGitCommand();
171
+ git.updateSubmoduleRemote(path);
172
+ await openSubmodulesModal();
173
+ }
139
174
  /** Syncs one submodule and refreshes the toolkit modal. */
140
175
  async function syncSubmodule(payload) {
141
176
  const path = payloadString(payload, 'path');
@@ -151,6 +186,12 @@ async function updateAllSubmodules() {
151
186
  git.updateAllSubmodules();
152
187
  await openSubmodulesModal();
153
188
  }
189
+ /** Updates all submodules from their configured branches and refreshes the toolkit modal. */
190
+ async function updateAllSubmodulesRemote() {
191
+ const git = createGitCommand();
192
+ git.updateAllSubmodulesRemote();
193
+ await openSubmodulesModal();
194
+ }
154
195
  /** Syncs all submodules recursively and refreshes the toolkit modal. */
155
196
  async function syncAllSubmodules() {
156
197
  const git = createGitCommand();
@@ -171,12 +212,18 @@ export function registerSubmoduleToolkit() {
171
212
  registerAction('submodules-update-all', async () => {
172
213
  return updateAllSubmodules();
173
214
  });
215
+ registerAction('submodules-update-all-remote', async () => {
216
+ return updateAllSubmodulesRemote();
217
+ });
174
218
  registerAction('submodules-sync-all', async () => {
175
219
  return syncAllSubmodules();
176
220
  });
177
221
  registerAction('submodules-update', async (payload) => {
178
222
  return updateSubmodule(asPayload(payload));
179
223
  });
224
+ registerAction('submodules-update-remote', async (payload) => {
225
+ return updateSubmoduleRemote(asPayload(payload));
226
+ });
180
227
  registerAction('submodules-sync', async (payload) => {
181
228
  return syncSubmodule(asPayload(payload));
182
229
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/git-plugin",
3
- "version": "0.1.0-nightly.20260406.4",
3
+ "version": "0.1.0-nightly.20260412.10",
4
4
  "description": "OpenVCS Git plugin - Node.js runtime",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://github.com/Open-VCS/OpenVCS-Plugin-Git",
@@ -18,7 +18,7 @@
18
18
  "openvcs": {
19
19
  "id": "openvcs.git",
20
20
  "name": "Git",
21
- "version": "0.1.0-nightly.20260406.4",
21
+ "version": "0.1.0-nightly.20260412.10",
22
22
  "author": "OpenVCS Contributors",
23
23
  "description": "Git VCS backend plugin for OpenVCS",
24
24
  "default_enabled": true,
@@ -37,7 +37,7 @@
37
37
  "build": "node ./node_modules/@openvcs/sdk/bin/openvcs.js build"
38
38
  },
39
39
  "dependencies": {
40
- "@openvcs/sdk": "^0.2.16"
40
+ "@openvcs/sdk": "^0.2.18-edge.20260411.9"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^25.5.0",
package/src/git.ts CHANGED
@@ -14,10 +14,12 @@ import type {
14
14
  } from '@openvcs/sdk/types';
15
15
  import type { GitCommandResult, RunGitOptions } from './plugin-types.js';
16
16
  import {
17
+ applySubmoduleStatusHints,
17
18
  asString,
18
19
  buildFetchArgs,
19
20
  buildPullFfOnlyArgs,
20
21
  buildPushArgs,
22
+ buildSubmoduleUpdateArgs,
21
23
  parseCommits,
22
24
  parseStatusOutput,
23
25
  } from './plugin-helpers.js';
@@ -148,7 +150,7 @@ export class GitCommand {
148
150
 
149
151
  status(): StatusParseResult & { exitCode: number } {
150
152
  const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
151
- const parsed = parseStatusOutput(result.stdout);
153
+ const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
152
154
  return { ...parsed, exitCode: result.status };
153
155
  }
154
156
 
@@ -343,9 +345,13 @@ export class GitCommand {
343
345
  return { commits, exitCode: result.status };
344
346
  }
345
347
 
346
- listSubmodules(): SubmoduleEntry[] {
348
+ /** Reads `.gitmodules` entries indexed by submodule name and path. */
349
+ private readSubmoduleConfig(): {
350
+ byName: Map<string, { name: string; path?: string; url?: string; branch?: string }>;
351
+ byPath: Map<string, { name: string; url?: string; branch?: string }>;
352
+ } {
347
353
  const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
348
- const configByName = new Map<string, { name: string; path?: string; url?: string; branch?: string }>();
354
+ const byName = new Map<string, { name: string; path?: string; url?: string; branch?: string }>();
349
355
 
350
356
  if (configResult.status === 0) {
351
357
  for (const entry of configResult.stdout.split('\0')) {
@@ -361,23 +367,34 @@ export class GitCommand {
361
367
  if (!match) continue;
362
368
 
363
369
  const [, name, field] = match;
364
- const target = configByName.get(name) || { name };
370
+ const target = byName.get(name) || { name };
365
371
 
366
372
  if (field === 'path') target.path = value;
367
373
  if (field === 'url') target.url = value;
368
374
  if (field === 'branch') target.branch = value;
369
375
 
370
- configByName.set(name, target);
376
+ byName.set(name, target);
371
377
  }
372
378
  }
373
379
 
374
- const configByPath = new Map<string, { name: string; url?: string; branch?: string }>();
375
- for (const entry of configByName.values()) {
380
+ const byPath = new Map<string, { name: string; url?: string; branch?: string }>();
381
+ for (const entry of byName.values()) {
376
382
  if (entry.path) {
377
- configByPath.set(entry.path, entry);
383
+ byPath.set(entry.path, entry);
378
384
  }
379
385
  }
380
386
 
387
+ return { byName, byPath };
388
+ }
389
+
390
+ /** Returns known submodule paths from `.gitmodules`. */
391
+ private listSubmodulePaths(): Set<string> {
392
+ return new Set(this.readSubmoduleConfig().byPath.keys());
393
+ }
394
+
395
+ listSubmodules(): SubmoduleEntry[] {
396
+ const { byPath: configByPath } = this.readSubmoduleConfig();
397
+
381
398
  const statusResult = this.run(['submodule', 'status', '--recursive']);
382
399
  if (statusResult.status !== 0) {
383
400
  return [];
@@ -423,11 +440,21 @@ export class GitCommand {
423
440
  }
424
441
 
425
442
  updateSubmodule(path: string): GitCommandResult {
426
- return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
443
+ return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
427
444
  }
428
445
 
429
446
  updateAllSubmodules(): GitCommandResult {
430
- return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
447
+ return this.runChecked(buildSubmoduleUpdateArgs({}), 'git-submodule-update-failed');
448
+ }
449
+
450
+ /** Updates one submodule from its configured branch recursively. */
451
+ updateSubmoduleRemote(path: string): GitCommandResult {
452
+ return this.runChecked(buildSubmoduleUpdateArgs({ path, remote: true }), 'git-submodule-update-remote-failed');
453
+ }
454
+
455
+ /** Updates all submodules from their configured branches recursively. */
456
+ updateAllSubmodulesRemote(): GitCommandResult {
457
+ return this.runChecked(buildSubmoduleUpdateArgs({ remote: true }), 'git-submodule-update-remote-failed');
431
458
  }
432
459
 
433
460
  syncSubmodule(path: string): GitCommandResult {
@@ -74,6 +74,14 @@ export function buildPushArgs(params: RequestParams): string[] {
74
74
  return args;
75
75
  }
76
76
 
77
+ /** Builds `git clone` arguments with recursive submodule initialization enabled. */
78
+ export function buildCloneArgs(params: RequestParams): string[] {
79
+ const args = ['clone', '--recurse-submodules'];
80
+ pushOptionalArg(args, params.url);
81
+ pushOptionalArg(args, params.dest);
82
+ return args;
83
+ }
84
+
77
85
  /** Builds `git pull --ff-only` arguments while omitting empty optional values. */
78
86
  export function buildPullFfOnlyArgs(params: RequestParams): string[] {
79
87
  const args = ['pull', '--ff-only'];
@@ -82,6 +90,22 @@ export function buildPullFfOnlyArgs(params: RequestParams): string[] {
82
90
  return args;
83
91
  }
84
92
 
93
+ /** Builds `git submodule update` arguments for pinned or remote-tracking updates. */
94
+ export function buildSubmoduleUpdateArgs(params: RequestParams): string[] {
95
+ const args = ['submodule', 'update', '--init', '--recursive'];
96
+
97
+ if (params.remote === true) {
98
+ args.push('--remote');
99
+ }
100
+
101
+ const path = asTrimmedString(params.path);
102
+ if (path) {
103
+ args.push('--', path);
104
+ }
105
+
106
+ return args;
107
+ }
108
+
85
109
  /** Parses `git status --porcelain=1 --branch -z -uall` output into OpenVCS payloads. */
86
110
  export function parseStatusOutput(output: string): StatusParseResult {
87
111
  const records = output.split('\0').filter(Boolean);
@@ -164,6 +188,40 @@ export function parseStatusOutput(output: string): StatusParseResult {
164
188
  };
165
189
  }
166
190
 
191
+ /** Applies submodule-specific status labels to known submodule paths. */
192
+ export function applySubmoduleStatusHints(
193
+ parsed: StatusParseResult,
194
+ submodulePaths: Iterable<string>,
195
+ ): StatusParseResult {
196
+ const knownPaths = new Set(
197
+ Array.from(submodulePaths)
198
+ .map((entry) => asTrimmedString(entry))
199
+ .filter(Boolean),
200
+ );
201
+
202
+ if (knownPaths.size === 0) {
203
+ return parsed;
204
+ }
205
+
206
+ return {
207
+ ...parsed,
208
+ payload: {
209
+ ...parsed.payload,
210
+ files: parsed.payload.files.map((file) => {
211
+ const path = asTrimmedString(file.path);
212
+ if (!knownPaths.has(path)) {
213
+ return file;
214
+ }
215
+
216
+ return {
217
+ ...file,
218
+ status: 'S',
219
+ };
220
+ }),
221
+ },
222
+ };
223
+ }
224
+
167
225
  /** Parses `git log` output into commit entries expected by the host. */
168
226
  export function parseCommits(raw: string): CommitEntry[] {
169
227
  const records = raw
@@ -14,6 +14,7 @@ import {
14
14
  asString,
15
15
  asStringArray,
16
16
  asTrimmedString,
17
+ buildCloneArgs,
17
18
  } from './plugin-helpers.js';
18
19
  import { GitCommand } from './git.js';
19
20
  import type { GitSession } from './plugin-types.js';
@@ -92,7 +93,7 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
92
93
  }
93
94
 
94
95
  const git = this.deps.createGitCommand(process.cwd());
95
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
96
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
96
97
  const lines = `${output.stdout}\n${output.stderr}`
97
98
  .split(/\r?\n/g)
98
99
  .map((line) => line.trim())
package/src/submodules.ts CHANGED
@@ -44,6 +44,12 @@ function buildSubmoduleRow(entry: SubmoduleEntry) {
44
44
  description,
45
45
  actions: [
46
46
  { type: 'button' as const, id: 'submodules-update', content: 'Update', payload: { path: entry.path } },
47
+ {
48
+ type: 'button' as const,
49
+ id: 'submodules-update-remote',
50
+ content: 'Update Remote',
51
+ payload: { path: entry.path },
52
+ },
47
53
  { type: 'button' as const, id: 'submodules-sync', content: 'Sync', payload: { path: entry.path } },
48
54
  {
49
55
  type: 'button' as const,
@@ -64,33 +70,60 @@ async function openSubmodulesModal(): Promise<unknown> {
64
70
 
65
71
  const modal = new ModalBuilder('Manage Submodules')
66
72
  .text('Review, add, update, sync, and remove submodules without leaving Git.')
73
+ .text('Use Update Remote to follow each submodule branch configured in .gitmodules.')
67
74
  .separator()
68
- .input('url', 'Submodule URL', {
69
- kind: 'url',
70
- placeholder: 'https://example.com/repo.git',
71
- })
72
- .input('path', 'Submodule Path', {
73
- placeholder: 'libs/example',
74
- })
75
- .input('name', 'Submodule Name', {
76
- placeholder: 'example',
77
- })
78
- .input('branch', 'Branch (optional)', {
79
- placeholder: 'main',
80
- })
81
- .button('submodules-add', 'Add Submodule', {
82
- variant: 'primary',
83
- align: 'centered',
84
- })
85
- .button('repo-submodules', 'Refresh', {
86
- align: 'centered',
87
- })
88
- .button('submodules-update-all', 'Update All (Recursive)', {
89
- align: 'centered',
90
- })
91
- .button('submodules-sync-all', 'Sync All', {
92
- align: 'centered',
93
- })
75
+ .verticalBox(
76
+ [
77
+ {
78
+ type: 'input' as const,
79
+ id: 'url',
80
+ label: 'Submodule URL',
81
+ kind: 'url' as const,
82
+ placeholder: 'https://example.com/repo.git',
83
+ },
84
+ {
85
+ type: 'grid' as const,
86
+ columns: 'minmax(0, 1fr) minmax(0, 1fr)',
87
+ gap: '.75rem',
88
+ content: [
89
+ {
90
+ type: 'input' as const,
91
+ id: 'path',
92
+ label: 'Submodule Path',
93
+ placeholder: 'libs/example',
94
+ },
95
+ {
96
+ type: 'input' as const,
97
+ id: 'name',
98
+ label: 'Submodule Name',
99
+ placeholder: 'example',
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ type: 'input' as const,
105
+ id: 'branch',
106
+ label: 'Branch (optional)',
107
+ placeholder: 'main',
108
+ },
109
+ ],
110
+ { gap: '1rem' },
111
+ )
112
+ .horizontalBox(
113
+ [
114
+ { type: 'button' as const, id: 'submodules-add', content: 'Add Submodule', variant: 'primary' as const },
115
+ { type: 'button' as const, id: 'repo-submodules', content: 'Refresh' },
116
+ ],
117
+ { gap: '.5rem', align: 'centered', wrap: true },
118
+ )
119
+ .horizontalBox(
120
+ [
121
+ { type: 'button' as const, id: 'submodules-update-all', content: 'Update All (Recursive)' },
122
+ { type: 'button' as const, id: 'submodules-update-all-remote', content: 'Update All From Branches' },
123
+ { type: 'button' as const, id: 'submodules-sync-all', content: 'Sync All' },
124
+ ],
125
+ { gap: '.5rem', align: 'centered', wrap: true },
126
+ )
94
127
  .separator()
95
128
  .list('submodules', {
96
129
  label: 'Submodules',
@@ -109,14 +142,19 @@ async function openRemoveConfirmationModal(path: string, name?: string): Promise
109
142
 
110
143
  const modal = new ModalBuilder('Confirm Submodule Removal')
111
144
  .text(`Remove the submodule at ${submodulePath}? This will deinitialize the submodule, remove it from the index, and delete its working tree entry.`)
112
- .button('repo-submodules', 'Back', {
113
- align: 'centered',
114
- })
115
- .button('submodules-remove-confirm', 'Remove Submodule', {
116
- variant: 'danger',
117
- align: 'centered',
118
- payload: { path: submodulePath, name: String(name || '').trim() || undefined },
119
- });
145
+ .horizontalBox(
146
+ [
147
+ { type: 'button' as const, id: 'repo-submodules', content: 'Back' },
148
+ {
149
+ type: 'button' as const,
150
+ id: 'submodules-remove-confirm',
151
+ content: 'Remove Submodule',
152
+ variant: 'danger' as const,
153
+ payload: { path: submodulePath, name: String(name || '').trim() || undefined },
154
+ },
155
+ ],
156
+ { gap: '.5rem', align: 'centered', wrap: true },
157
+ );
120
158
 
121
159
  return modal.open();
122
160
  }
@@ -155,6 +193,15 @@ async function updateSubmodule(payload: ModalActionPayload): Promise<void> {
155
193
  await openSubmodulesModal();
156
194
  }
157
195
 
196
+ /** Updates one submodule from its configured branch and refreshes the toolkit modal. */
197
+ async function updateSubmoduleRemote(payload: ModalActionPayload): Promise<void> {
198
+ const path = payloadString(payload, 'path');
199
+ if (!path) return;
200
+ const git = createGitCommand();
201
+ git.updateSubmoduleRemote(path);
202
+ await openSubmodulesModal();
203
+ }
204
+
158
205
  /** Syncs one submodule and refreshes the toolkit modal. */
159
206
  async function syncSubmodule(payload: ModalActionPayload): Promise<void> {
160
207
  const path = payloadString(payload, 'path');
@@ -171,6 +218,13 @@ async function updateAllSubmodules(): Promise<void> {
171
218
  await openSubmodulesModal();
172
219
  }
173
220
 
221
+ /** Updates all submodules from their configured branches and refreshes the toolkit modal. */
222
+ async function updateAllSubmodulesRemote(): Promise<void> {
223
+ const git = createGitCommand();
224
+ git.updateAllSubmodulesRemote();
225
+ await openSubmodulesModal();
226
+ }
227
+
174
228
  /** Syncs all submodules recursively and refreshes the toolkit modal. */
175
229
  async function syncAllSubmodules(): Promise<void> {
176
230
  const git = createGitCommand();
@@ -196,6 +250,10 @@ export function registerSubmoduleToolkit(): void {
196
250
  return updateAllSubmodules();
197
251
  });
198
252
 
253
+ registerAction('submodules-update-all-remote', async () => {
254
+ return updateAllSubmodulesRemote();
255
+ });
256
+
199
257
  registerAction('submodules-sync-all', async () => {
200
258
  return syncAllSubmodules();
201
259
  });
@@ -204,6 +262,10 @@ export function registerSubmoduleToolkit(): void {
204
262
  return updateSubmodule(asPayload(payload));
205
263
  });
206
264
 
265
+ registerAction('submodules-update-remote', async (payload?: unknown) => {
266
+ return updateSubmoduleRemote(asPayload(payload));
267
+ });
268
+
207
269
  registerAction('submodules-sync', async (payload?: unknown) => {
208
270
  return syncSubmodule(asPayload(payload));
209
271
  });