@openvcs/git-plugin 0.1.0-beta.2 → 0.1.0-edge.20260422.23

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
@@ -56,11 +65,13 @@ The npm package can be consumed from prerelease channels published by CI:
56
65
 
57
66
  - `latest`: stable releases
58
67
  - `beta`: builds from the `Beta` branch
68
+ - `edge`: working builds from `Dev` push commits
59
69
  - `nightly`: scheduled builds from `Dev` when there are changes since the last nightly
60
70
 
61
71
  Examples:
62
72
 
63
73
  ```bash
74
+ npm install @openvcs/git-plugin@edge
64
75
  npm install @openvcs/git-plugin@beta
65
76
  npm install @openvcs/git-plugin@nightly
66
77
  ```
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() {
@@ -163,26 +163,41 @@ export class GitCommand {
163
163
  const args = buildPullFfOnlyArgs(options);
164
164
  return this.runChecked(args, 'git-pull-failed');
165
165
  }
166
- commit(message) {
167
- return this.runChecked(['commit', '-m', message], 'git-commit-failed');
166
+ /** Returns the current HEAD commit id. */
167
+ currentHead() {
168
+ return this.runChecked(['rev-parse', 'HEAD'], 'git-head-failed').stdout.trim();
168
169
  }
169
- commitIndex(message, name, email, paths) {
170
- const args = ['commit'];
171
- if (paths && paths.length > 0) {
172
- args.push('--', ...paths);
173
- }
174
- else {
175
- args.push('-a');
176
- }
170
+ /** Creates a commit, optionally limited to the provided paths. */
171
+ commit(message, name, email, paths) {
172
+ const execArgs = [
173
+ ...(name ? ['-c', `user.name=${name}`] : []),
174
+ ...(email ? ['-c', `user.email=${email}`] : []),
175
+ 'commit',
176
+ '-m',
177
+ message,
178
+ ...(paths && paths.length > 0 ? ['--', ...paths] : []),
179
+ ];
180
+ return this.runChecked(execArgs, 'git-commit-failed');
181
+ }
182
+ /** Creates a commit from the current index only. */
183
+ commitIndex(message, name, email) {
177
184
  const commitMessage = message || 'Stage changes';
178
185
  const execArgs = [
179
186
  ...(name ? ['-c', `user.name=${name}`] : []),
180
187
  ...(email ? ['-c', `user.email=${email}`] : []),
181
- ...args,
182
- '-m', commitMessage,
188
+ 'commit',
189
+ '-m',
190
+ commitMessage,
183
191
  ];
184
192
  return this.runChecked(execArgs, 'git-commit-failed');
185
193
  }
194
+ /** Stages the provided repository-relative paths into the index. */
195
+ stagePaths(paths) {
196
+ if (paths.length === 0) {
197
+ return;
198
+ }
199
+ this.runChecked(['add', '-A', '--', ...paths], 'git-stage-paths-failed');
200
+ }
186
201
  listCommits(options = {}) {
187
202
  const args = ['log', '--all'];
188
203
  if (options.topo_order) {
@@ -206,7 +221,7 @@ export class GitCommand {
206
221
  if (options.until_utc) {
207
222
  args.push(`--until=${options.until_utc}`);
208
223
  }
209
- args.push('--pretty=format:%H%f%x00%aN%x00%aI%x00%P%x1e');
224
+ args.push('--pretty=format:%H%x00%s%x00%aN%x00%aI%x00%P%x1e');
210
225
  if (options.branch) {
211
226
  args.push(options.branch);
212
227
  }
@@ -217,9 +232,10 @@ export class GitCommand {
217
232
  const commits = parseCommits(result.stdout);
218
233
  return { commits, exitCode: result.status };
219
234
  }
220
- listSubmodules() {
235
+ /** Reads `.gitmodules` entries indexed by submodule name and path. */
236
+ readSubmoduleConfig() {
221
237
  const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
222
- const configByName = new Map();
238
+ const byName = new Map();
223
239
  if (configResult.status === 0) {
224
240
  for (const entry of configResult.stdout.split('\0')) {
225
241
  const trimmed = entry.trim();
@@ -234,22 +250,30 @@ export class GitCommand {
234
250
  if (!match)
235
251
  continue;
236
252
  const [, name, field] = match;
237
- const target = configByName.get(name) || { name };
253
+ const target = byName.get(name) || { name };
238
254
  if (field === 'path')
239
255
  target.path = value;
240
256
  if (field === 'url')
241
257
  target.url = value;
242
258
  if (field === 'branch')
243
259
  target.branch = value;
244
- configByName.set(name, target);
260
+ byName.set(name, target);
245
261
  }
246
262
  }
247
- const configByPath = new Map();
248
- for (const entry of configByName.values()) {
263
+ const byPath = new Map();
264
+ for (const entry of byName.values()) {
249
265
  if (entry.path) {
250
- configByPath.set(entry.path, entry);
266
+ byPath.set(entry.path, entry);
251
267
  }
252
268
  }
269
+ return { byName, byPath };
270
+ }
271
+ /** Returns known submodule paths from `.gitmodules`. */
272
+ listSubmodulePaths() {
273
+ return new Set(this.readSubmoduleConfig().byPath.keys());
274
+ }
275
+ listSubmodules() {
276
+ const { byPath: configByPath } = this.readSubmoduleConfig();
253
277
  const statusResult = this.run(['submodule', 'status', '--recursive']);
254
278
  if (statusResult.status !== 0) {
255
279
  return [];
@@ -295,10 +319,18 @@ export class GitCommand {
295
319
  return this.runChecked(args, 'git-submodule-add-failed');
296
320
  }
297
321
  updateSubmodule(path) {
298
- return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
322
+ return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
299
323
  }
300
324
  updateAllSubmodules() {
301
- return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
325
+ return this.runChecked(buildSubmoduleUpdateArgs({}), 'git-submodule-update-failed');
326
+ }
327
+ /** Updates one submodule from its configured branch recursively. */
328
+ updateSubmoduleRemote(path) {
329
+ return this.runChecked(buildSubmoduleUpdateArgs({ path, remote: true }), 'git-submodule-update-remote-failed');
330
+ }
331
+ /** Updates all submodules from their configured branches recursively. */
332
+ updateAllSubmodulesRemote() {
333
+ return this.runChecked(buildSubmoduleUpdateArgs({ remote: true }), 'git-submodule-update-remote-failed');
302
334
  }
303
335
  syncSubmodule(path) {
304
336
  return this.runChecked(['submodule', 'sync', '--recursive', '--', path], 'git-submodule-sync-failed');
@@ -354,8 +386,11 @@ export class GitCommand {
354
386
  hashObject(content) {
355
387
  return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
356
388
  }
389
+ /** Stages a textual patch into the index without requiring worktree/index parity. */
357
390
  stagePatch(patch) {
358
- this.runChecked(['apply', '--3way', '--index'], 'git-stage-patch-failed', { stdin: patch });
391
+ this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
392
+ stdin: patch,
393
+ });
359
394
  }
360
395
  applyReversePatch(patch) {
361
396
  this.runChecked(['apply', '-R', patch], 'git-apply-reverse-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,8 +1,63 @@
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, parseStatusOutput, } from './plugin-helpers.js';
5
5
  import { GitCommand } from './git.js';
6
+ /** Reduces a file status string to the primary status code needed for discard routing. */
7
+ function getPrimaryDiscardStatus(status) {
8
+ const normalized = asTrimmedString(status);
9
+ if (!normalized) {
10
+ return 'M';
11
+ }
12
+ for (const candidate of ['?', 'R', 'C', 'A', 'D', 'U', 'T', 'S', 'M']) {
13
+ if (normalized.includes(candidate)) {
14
+ return candidate;
15
+ }
16
+ }
17
+ return normalized[0] ?? 'M';
18
+ }
19
+ /** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
20
+ export function planDiscardPaths(statusOutput) {
21
+ const restore = new Set();
22
+ const unstageThenRemove = new Set();
23
+ const clean = new Set();
24
+ const parsed = parseStatusOutput(statusOutput);
25
+ for (const file of parsed.payload.files) {
26
+ const path = asTrimmedString(file.path);
27
+ const oldPath = asTrimmedString(file.old_path);
28
+ const primaryStatus = getPrimaryDiscardStatus(file.status);
29
+ if (!path) {
30
+ continue;
31
+ }
32
+ if (primaryStatus === '?') {
33
+ clean.add(path);
34
+ continue;
35
+ }
36
+ if (primaryStatus === 'R') {
37
+ if (oldPath) {
38
+ restore.add(oldPath);
39
+ }
40
+ if (file.staged) {
41
+ unstageThenRemove.add(path);
42
+ }
43
+ clean.add(path);
44
+ continue;
45
+ }
46
+ if (primaryStatus === 'C' || primaryStatus === 'A') {
47
+ if (file.staged) {
48
+ unstageThenRemove.add(path);
49
+ }
50
+ clean.add(path);
51
+ continue;
52
+ }
53
+ restore.add(path);
54
+ }
55
+ return {
56
+ restore: Array.from(restore),
57
+ unstageThenRemove: Array.from(unstageThenRemove),
58
+ clean: Array.from(clean),
59
+ };
60
+ }
6
61
  /** Implements the Git-backed `vcs.*` delegate surface for the SDK runtime. */
7
62
  export class GitVcsDelegates extends VcsDelegateBase {
8
63
  /** Returns the required repository worktree path for a session id. */
@@ -45,7 +100,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
45
100
  throw pluginError('vcs-clone-invalid-args', 'url and dest are required');
46
101
  }
47
102
  const git = this.deps.createGitCommand(process.cwd());
48
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
103
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
49
104
  const lines = `${output.stdout}\n${output.stderr}`
50
105
  .split(/\r?\n/g)
51
106
  .map((line) => line.trim())
@@ -159,15 +214,13 @@ export class GitVcsDelegates extends VcsDelegateBase {
159
214
  }
160
215
  commit(params, _context) {
161
216
  const git = this.requireGit(params.session_id);
162
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
163
- git.commit(asTrimmedString(params.message));
164
- return result.stdout.trim();
217
+ git.commit(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email), asStringArray(params.paths));
218
+ return git.currentHead();
165
219
  }
166
220
  commitIndex(params, _context) {
167
221
  const git = this.requireGit(params.session_id);
168
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
169
- git.commitIndex(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email), asStringArray(params.paths));
170
- return result.stdout.trim();
222
+ git.commitIndex(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email));
223
+ return git.currentHead();
171
224
  }
172
225
  getStatusSummary(params, _context) {
173
226
  const git = this.requireGit(params.session_id);
@@ -225,13 +278,28 @@ export class GitVcsDelegates extends VcsDelegateBase {
225
278
  git.stagePatch(asString(params.patch));
226
279
  return null;
227
280
  }
281
+ stagePaths(params, _context) {
282
+ const git = this.requireGit(params.session_id);
283
+ git.stagePaths(asStringArray(params.paths));
284
+ return null;
285
+ }
228
286
  discardPaths(params, _context) {
229
287
  const git = this.requireGit(params.session_id);
230
288
  const paths = asStringArray(params.paths);
231
289
  if (paths.length === 0) {
232
290
  return null;
233
291
  }
234
- git.runChecked(['checkout', '--', ...paths], 'git-discard-paths-failed');
292
+ const status = git.runChecked(['status', '--porcelain=1', '-z', '-uall', '--', ...paths], 'git-discard-paths-failed');
293
+ const discardPlan = planDiscardPaths(status.stdout);
294
+ if (discardPlan.unstageThenRemove.length > 0) {
295
+ git.runChecked(['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove], 'git-discard-paths-failed');
296
+ }
297
+ if (discardPlan.restore.length > 0) {
298
+ git.runChecked(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore], 'git-discard-paths-failed');
299
+ }
300
+ if (discardPlan.clean.length > 0) {
301
+ git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
302
+ }
235
303
  return null;
236
304
  }
237
305
  applyReversePatch(params, _context) {
package/bin/plugin.js CHANGED
@@ -38,7 +38,7 @@ export function OnPluginStart() {
38
38
  }
39
39
  const delegates = new GitVcsDelegates(createGitRuntimeDependencies());
40
40
  PluginDefinition.vcs = delegates.toDelegates();
41
- const repoMenu = getOrCreateMenu('repository', 'Repository');
41
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
42
42
  if (repoMenu) {
43
43
  repoMenu.addItem({ label: 'Edit .gitignore', action: 'repo-edit-gitignore' });
44
44
  repoMenu.addItem({ label: 'Edit .gitattributes', action: 'repo-edit-gitattributes' });
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();
@@ -159,7 +200,7 @@ async function syncAllSubmodules() {
159
200
  }
160
201
  /** Registers the Git submodule toolkit menu and action handlers. */
161
202
  export function registerSubmoduleToolkit() {
162
- const repoMenu = getOrCreateMenu('repository', 'Repository');
203
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
163
204
  repoMenu?.addItem({ label: 'Submodules', action: 'repo-submodules' });
164
205
  registerAction('repo-submodules', async () => {
165
206
  console.log('Git submodules: repo-submodules action invoked');
@@ -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-beta.2",
3
+ "version": "0.1.0-edge.20260422.23",
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-beta.2",
21
+ "version": "0.1.0-edge.20260422.23",
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": "edge"
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
 
@@ -272,31 +274,49 @@ export class GitCommand {
272
274
  return this.runChecked(args, 'git-pull-failed');
273
275
  }
274
276
 
275
- commit(message: string): GitCommandResult {
276
- return this.runChecked(['commit', '-m', message], 'git-commit-failed');
277
+ /** Returns the current HEAD commit id. */
278
+ currentHead(): string {
279
+ return this.runChecked(['rev-parse', 'HEAD'], 'git-head-failed').stdout.trim();
277
280
  }
278
281
 
279
- commitIndex(message?: string, name?: string, email?: string, paths?: string[]): GitCommandResult {
280
- const args = ['commit'];
281
-
282
- if (paths && paths.length > 0) {
283
- args.push('--', ...paths);
284
- } else {
285
- args.push('-a');
286
- }
287
-
282
+ /** Creates a commit, optionally limited to the provided paths. */
283
+ commit(message: string, name?: string, email?: string, paths?: string[]): GitCommandResult {
284
+ const execArgs = [
285
+ ...(name ? ['-c', `user.name=${name}`] : []),
286
+ ...(email ? ['-c', `user.email=${email}`] : []),
287
+ 'commit',
288
+ '-m',
289
+ message,
290
+ ...(paths && paths.length > 0 ? ['--', ...paths] : []),
291
+ ];
292
+
293
+ return this.runChecked(execArgs, 'git-commit-failed');
294
+ }
295
+
296
+ /** Creates a commit from the current index only. */
297
+ commitIndex(message?: string, name?: string, email?: string): GitCommandResult {
288
298
  const commitMessage = message || 'Stage changes';
289
-
299
+
290
300
  const execArgs = [
291
301
  ...(name ? ['-c', `user.name=${name}`] : []),
292
302
  ...(email ? ['-c', `user.email=${email}`] : []),
293
- ...args,
294
- '-m', commitMessage,
303
+ 'commit',
304
+ '-m',
305
+ commitMessage,
295
306
  ];
296
-
307
+
297
308
  return this.runChecked(execArgs, 'git-commit-failed');
298
309
  }
299
310
 
311
+ /** Stages the provided repository-relative paths into the index. */
312
+ stagePaths(paths: string[]): void {
313
+ if (paths.length === 0) {
314
+ return;
315
+ }
316
+
317
+ this.runChecked(['add', '-A', '--', ...paths], 'git-stage-paths-failed');
318
+ }
319
+
300
320
  listCommits(options: ListCommitsOptions = {}): { commits: CommitEntry[]; exitCode: number } {
301
321
  const args = ['log', '--all'];
302
322
 
@@ -328,7 +348,7 @@ export class GitCommand {
328
348
  args.push(`--until=${options.until_utc}`);
329
349
  }
330
350
 
331
- args.push('--pretty=format:%H%f%x00%aN%x00%aI%x00%P%x1e');
351
+ args.push('--pretty=format:%H%x00%s%x00%aN%x00%aI%x00%P%x1e');
332
352
 
333
353
  if (options.branch) {
334
354
  args.push(options.branch);
@@ -343,9 +363,13 @@ export class GitCommand {
343
363
  return { commits, exitCode: result.status };
344
364
  }
345
365
 
346
- listSubmodules(): SubmoduleEntry[] {
366
+ /** Reads `.gitmodules` entries indexed by submodule name and path. */
367
+ private readSubmoduleConfig(): {
368
+ byName: Map<string, { name: string; path?: string; url?: string; branch?: string }>;
369
+ byPath: Map<string, { name: string; url?: string; branch?: string }>;
370
+ } {
347
371
  const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
348
- const configByName = new Map<string, { name: string; path?: string; url?: string; branch?: string }>();
372
+ const byName = new Map<string, { name: string; path?: string; url?: string; branch?: string }>();
349
373
 
350
374
  if (configResult.status === 0) {
351
375
  for (const entry of configResult.stdout.split('\0')) {
@@ -361,23 +385,34 @@ export class GitCommand {
361
385
  if (!match) continue;
362
386
 
363
387
  const [, name, field] = match;
364
- const target = configByName.get(name) || { name };
388
+ const target = byName.get(name) || { name };
365
389
 
366
390
  if (field === 'path') target.path = value;
367
391
  if (field === 'url') target.url = value;
368
392
  if (field === 'branch') target.branch = value;
369
393
 
370
- configByName.set(name, target);
394
+ byName.set(name, target);
371
395
  }
372
396
  }
373
397
 
374
- const configByPath = new Map<string, { name: string; url?: string; branch?: string }>();
375
- for (const entry of configByName.values()) {
398
+ const byPath = new Map<string, { name: string; url?: string; branch?: string }>();
399
+ for (const entry of byName.values()) {
376
400
  if (entry.path) {
377
- configByPath.set(entry.path, entry);
401
+ byPath.set(entry.path, entry);
378
402
  }
379
403
  }
380
404
 
405
+ return { byName, byPath };
406
+ }
407
+
408
+ /** Returns known submodule paths from `.gitmodules`. */
409
+ private listSubmodulePaths(): Set<string> {
410
+ return new Set(this.readSubmoduleConfig().byPath.keys());
411
+ }
412
+
413
+ listSubmodules(): SubmoduleEntry[] {
414
+ const { byPath: configByPath } = this.readSubmoduleConfig();
415
+
381
416
  const statusResult = this.run(['submodule', 'status', '--recursive']);
382
417
  if (statusResult.status !== 0) {
383
418
  return [];
@@ -423,11 +458,21 @@ export class GitCommand {
423
458
  }
424
459
 
425
460
  updateSubmodule(path: string): GitCommandResult {
426
- return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
461
+ return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
427
462
  }
428
463
 
429
464
  updateAllSubmodules(): GitCommandResult {
430
- return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
465
+ return this.runChecked(buildSubmoduleUpdateArgs({}), 'git-submodule-update-failed');
466
+ }
467
+
468
+ /** Updates one submodule from its configured branch recursively. */
469
+ updateSubmoduleRemote(path: string): GitCommandResult {
470
+ return this.runChecked(buildSubmoduleUpdateArgs({ path, remote: true }), 'git-submodule-update-remote-failed');
471
+ }
472
+
473
+ /** Updates all submodules from their configured branches recursively. */
474
+ updateAllSubmodulesRemote(): GitCommandResult {
475
+ return this.runChecked(buildSubmoduleUpdateArgs({ remote: true }), 'git-submodule-update-remote-failed');
431
476
  }
432
477
 
433
478
  syncSubmodule(path: string): GitCommandResult {
@@ -498,8 +543,11 @@ export class GitCommand {
498
543
  return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
499
544
  }
500
545
 
546
+ /** Stages a textual patch into the index without requiring worktree/index parity. */
501
547
  stagePatch(patch: string): void {
502
- this.runChecked(['apply', '--3way', '--index'], 'git-stage-patch-failed', { stdin: patch });
548
+ this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
549
+ stdin: patch,
550
+ });
503
551
  }
504
552
 
505
553
  applyReversePatch(patch: string): void {
@@ -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,10 +14,88 @@ import {
14
14
  asString,
15
15
  asStringArray,
16
16
  asTrimmedString,
17
+ buildCloneArgs,
18
+ parseStatusOutput,
17
19
  } from './plugin-helpers.js';
18
20
  import { GitCommand } from './git.js';
19
21
  import type { GitSession } from './plugin-types.js';
20
22
 
23
+ /** Describes the Git operations needed to discard a set of paths safely. */
24
+ export interface DiscardPathPlan {
25
+ /** Restores tracked paths from HEAD in both the index and worktree. */
26
+ restore: string[];
27
+ /** Removes newly added index entries before deleting their worktree files. */
28
+ unstageThenRemove: string[];
29
+ /** Deletes untracked worktree paths after index state has been corrected. */
30
+ clean: string[];
31
+ }
32
+
33
+ /** Reduces a file status string to the primary status code needed for discard routing. */
34
+ function getPrimaryDiscardStatus(status: string): string {
35
+ const normalized = asTrimmedString(status);
36
+ if (!normalized) {
37
+ return 'M';
38
+ }
39
+
40
+ for (const candidate of ['?', 'R', 'C', 'A', 'D', 'U', 'T', 'S', 'M']) {
41
+ if (normalized.includes(candidate)) {
42
+ return candidate;
43
+ }
44
+ }
45
+
46
+ return normalized[0] ?? 'M';
47
+ }
48
+
49
+ /** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
50
+ export function planDiscardPaths(statusOutput: string): DiscardPathPlan {
51
+ const restore = new Set<string>();
52
+ const unstageThenRemove = new Set<string>();
53
+ const clean = new Set<string>();
54
+ const parsed = parseStatusOutput(statusOutput);
55
+
56
+ for (const file of parsed.payload.files) {
57
+ const path = asTrimmedString(file.path);
58
+ const oldPath = asTrimmedString(file.old_path);
59
+ const primaryStatus = getPrimaryDiscardStatus(file.status);
60
+
61
+ if (!path) {
62
+ continue;
63
+ }
64
+
65
+ if (primaryStatus === '?') {
66
+ clean.add(path);
67
+ continue;
68
+ }
69
+
70
+ if (primaryStatus === 'R') {
71
+ if (oldPath) {
72
+ restore.add(oldPath);
73
+ }
74
+ if (file.staged) {
75
+ unstageThenRemove.add(path);
76
+ }
77
+ clean.add(path);
78
+ continue;
79
+ }
80
+
81
+ if (primaryStatus === 'C' || primaryStatus === 'A') {
82
+ if (file.staged) {
83
+ unstageThenRemove.add(path);
84
+ }
85
+ clean.add(path);
86
+ continue;
87
+ }
88
+
89
+ restore.add(path);
90
+ }
91
+
92
+ return {
93
+ restore: Array.from(restore),
94
+ unstageThenRemove: Array.from(unstageThenRemove),
95
+ clean: Array.from(clean),
96
+ };
97
+ }
98
+
21
99
  /** Describes the Git runtime services consumed by the VCS delegates. */
22
100
  export interface GitRuntimeDependencies {
23
101
  /** Allocates a new repository session and returns its id. */
@@ -92,7 +170,7 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
92
170
  }
93
171
 
94
172
  const git = this.deps.createGitCommand(process.cwd());
95
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
173
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
96
174
  const lines = `${output.stdout}\n${output.stderr}`
97
175
  .split(/\r?\n/g)
98
176
  .map((line) => line.trim())
@@ -270,9 +348,13 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
270
348
  _context: PluginRuntimeContext,
271
349
  ): string {
272
350
  const git = this.requireGit(params.session_id);
273
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
274
- git.commit(asTrimmedString(params.message));
275
- return result.stdout.trim();
351
+ git.commit(
352
+ asTrimmedString(params.message),
353
+ asTrimmedString(params.name),
354
+ asTrimmedString(params.email),
355
+ asStringArray(params.paths),
356
+ );
357
+ return git.currentHead();
276
358
  }
277
359
 
278
360
  override commitIndex(
@@ -280,14 +362,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
280
362
  _context: PluginRuntimeContext,
281
363
  ): string {
282
364
  const git = this.requireGit(params.session_id);
283
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
284
365
  git.commitIndex(
285
366
  asTrimmedString(params.message),
286
367
  asTrimmedString(params.name),
287
368
  asTrimmedString(params.email),
288
- asStringArray(params.paths),
289
369
  );
290
- return result.stdout.trim();
370
+ return git.currentHead();
291
371
  }
292
372
 
293
373
  override getStatusSummary(
@@ -385,6 +465,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
385
465
  return null;
386
466
  }
387
467
 
468
+ override stagePaths(
469
+ params: OpenVcs.VcsStagePathsParams,
470
+ _context: PluginRuntimeContext,
471
+ ): null {
472
+ const git = this.requireGit(params.session_id);
473
+ git.stagePaths(asStringArray(params.paths));
474
+ return null;
475
+ }
476
+
388
477
  override discardPaths(
389
478
  params: OpenVcs.VcsDiscardPathsParams,
390
479
  _context: PluginRuntimeContext,
@@ -395,7 +484,29 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
395
484
  return null;
396
485
  }
397
486
 
398
- git.runChecked(['checkout', '--', ...paths], 'git-discard-paths-failed');
487
+ const status = git.runChecked(
488
+ ['status', '--porcelain=1', '-z', '-uall', '--', ...paths],
489
+ 'git-discard-paths-failed',
490
+ );
491
+ const discardPlan = planDiscardPaths(status.stdout);
492
+
493
+ if (discardPlan.unstageThenRemove.length > 0) {
494
+ git.runChecked(
495
+ ['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove],
496
+ 'git-discard-paths-failed',
497
+ );
498
+ }
499
+
500
+ if (discardPlan.restore.length > 0) {
501
+ git.runChecked(
502
+ ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore],
503
+ 'git-discard-paths-failed',
504
+ );
505
+ }
506
+
507
+ if (discardPlan.clean.length > 0) {
508
+ git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
509
+ }
399
510
  return null;
400
511
  }
401
512
 
package/src/plugin.ts CHANGED
@@ -58,7 +58,7 @@ export function OnPluginStart(): void {
58
58
  const delegates = new GitVcsDelegates(createGitRuntimeDependencies());
59
59
  PluginDefinition.vcs = delegates.toDelegates();
60
60
 
61
- const repoMenu = getOrCreateMenu('repository', 'Repository');
61
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
62
62
  if (repoMenu) {
63
63
  repoMenu.addItem({ label: 'Edit .gitignore', action: 'repo-edit-gitignore' });
64
64
  repoMenu.addItem({ label: 'Edit .gitattributes', action: 'repo-edit-gitattributes' });
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();
@@ -180,7 +234,7 @@ async function syncAllSubmodules(): Promise<void> {
180
234
 
181
235
  /** Registers the Git submodule toolkit menu and action handlers. */
182
236
  export function registerSubmoduleToolkit(): void {
183
- const repoMenu = getOrCreateMenu('repository', 'Repository');
237
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
184
238
  repoMenu?.addItem({ label: 'Submodules', action: 'repo-submodules' });
185
239
 
186
240
  registerAction('repo-submodules', async () => {
@@ -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
  });