@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 +8 -0
- package/README.md +10 -1
- package/bin/git.js +28 -11
- package/bin/plugin-helpers.js +44 -0
- package/bin/plugin-request-handler.js +2 -2
- package/bin/submodules.js +81 -34
- package/package.json +3 -3
- package/src/git.ts +37 -10
- package/src/plugin-helpers.ts +58 -0
- package/src/plugin-request-handler.ts +2 -1
- package/src/submodules.ts +96 -34
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
245
|
+
byName.set(name, target);
|
|
245
246
|
}
|
|
246
247
|
}
|
|
247
|
-
const
|
|
248
|
-
for (const entry of
|
|
248
|
+
const byPath = new Map();
|
|
249
|
+
for (const entry of byName.values()) {
|
|
249
250
|
if (entry.path) {
|
|
250
|
-
|
|
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(
|
|
307
|
+
return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
|
|
299
308
|
}
|
|
300
309
|
updateAllSubmodules() {
|
|
301
|
-
return this.runChecked(
|
|
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');
|
package/bin/plugin-helpers.js
CHANGED
|
@@ -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(
|
|
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
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
376
|
+
byName.set(name, target);
|
|
371
377
|
}
|
|
372
378
|
}
|
|
373
379
|
|
|
374
|
-
const
|
|
375
|
-
for (const entry of
|
|
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
|
-
|
|
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(
|
|
443
|
+
return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
|
|
427
444
|
}
|
|
428
445
|
|
|
429
446
|
updateAllSubmodules(): GitCommandResult {
|
|
430
|
-
return this.runChecked(
|
|
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 {
|
package/src/plugin-helpers.ts
CHANGED
|
@@ -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(
|
|
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
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
});
|