@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 +8 -0
- package/README.md +12 -1
- package/bin/git.js +60 -25
- package/bin/plugin-helpers.js +44 -0
- package/bin/plugin-request-handler.js +77 -9
- package/bin/plugin.js +1 -1
- package/bin/submodules.js +82 -35
- package/package.json +3 -3
- package/src/git.ts +75 -27
- package/src/plugin-helpers.ts +58 -0
- package/src/plugin-request-handler.ts +119 -8
- package/src/plugin.ts +1 -1
- package/src/submodules.ts +97 -35
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
|
|
@@ -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
|
|
167
|
-
|
|
166
|
+
/** Returns the current HEAD commit id. */
|
|
167
|
+
currentHead() {
|
|
168
|
+
return this.runChecked(['rev-parse', 'HEAD'], 'git-head-failed').stdout.trim();
|
|
168
169
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
182
|
-
'-m',
|
|
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%
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
260
|
+
byName.set(name, target);
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
|
-
const
|
|
248
|
-
for (const entry of
|
|
263
|
+
const byPath = new Map();
|
|
264
|
+
for (const entry of byName.values()) {
|
|
249
265
|
if (entry.path) {
|
|
250
|
-
|
|
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(
|
|
322
|
+
return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
|
|
299
323
|
}
|
|
300
324
|
updateAllSubmodules() {
|
|
301
|
-
return this.runChecked(
|
|
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', '--
|
|
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');
|
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,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(
|
|
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
|
-
|
|
163
|
-
git.
|
|
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
|
-
|
|
169
|
-
git.
|
|
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(['
|
|
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
|
-
.
|
|
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();
|
|
@@ -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-
|
|
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-
|
|
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": "
|
|
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
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
294
|
-
'-m',
|
|
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%
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
394
|
+
byName.set(name, target);
|
|
371
395
|
}
|
|
372
396
|
}
|
|
373
397
|
|
|
374
|
-
const
|
|
375
|
-
for (const entry of
|
|
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
|
-
|
|
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(
|
|
461
|
+
return this.runChecked(buildSubmoduleUpdateArgs({ path }), 'git-submodule-update-failed');
|
|
427
462
|
}
|
|
428
463
|
|
|
429
464
|
updateAllSubmodules(): GitCommandResult {
|
|
430
|
-
return this.runChecked(
|
|
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', '--
|
|
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 {
|
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,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(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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(
|
|
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
|
-
.
|
|
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();
|
|
@@ -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
|
});
|