@openvcs/git-plugin 0.1.0 → 0.2.0-beta.40

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');
@@ -366,9 +401,10 @@ export class GitCommand {
366
401
  resetSoftTo(ref) {
367
402
  this.runChecked(['reset', '--soft', ref], 'git-reset-soft-failed');
368
403
  }
404
+ /** Reads the effective Git commit identity from config. */
369
405
  getIdentity() {
370
- const nameResult = this.run(['config', '--local', 'user.name']);
371
- const emailResult = this.run(['config', '--local', 'user.email']);
406
+ const nameResult = this.run(['config', '--get', 'user.name']);
407
+ const emailResult = this.run(['config', '--get', 'user.email']);
372
408
  if (nameResult.status !== 0 || emailResult.status !== 0) {
373
409
  return null;
374
410
  }
@@ -377,6 +413,7 @@ export class GitCommand {
377
413
  email: emailResult.stdout.trim(),
378
414
  };
379
415
  }
416
+ /** Stores repository-local commit identity in Git config. */
380
417
  setIdentityLocal(name, email) {
381
418
  this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
382
419
  this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-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,67 @@
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
+ /** Returns an optional boolean only when the input is already a boolean. */
7
+ function asOptionalBoolean(value) {
8
+ return typeof value === 'boolean' ? value : undefined;
9
+ }
10
+ /** Reduces a file status string to the primary status code needed for discard routing. */
11
+ function getPrimaryDiscardStatus(status) {
12
+ const normalized = asTrimmedString(status);
13
+ if (!normalized) {
14
+ return 'M';
15
+ }
16
+ for (const candidate of ['?', 'R', 'C', 'A', 'D', 'U', 'T', 'S', 'M']) {
17
+ if (normalized.includes(candidate)) {
18
+ return candidate;
19
+ }
20
+ }
21
+ return normalized[0] ?? 'M';
22
+ }
23
+ /** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
24
+ export function planDiscardPaths(statusOutput) {
25
+ const restore = new Set();
26
+ const unstageThenRemove = new Set();
27
+ const clean = new Set();
28
+ const parsed = parseStatusOutput(statusOutput);
29
+ for (const file of parsed.payload.files) {
30
+ const path = asTrimmedString(file.path);
31
+ const oldPath = asTrimmedString(file.old_path);
32
+ const primaryStatus = getPrimaryDiscardStatus(file.status);
33
+ if (!path) {
34
+ continue;
35
+ }
36
+ if (primaryStatus === '?') {
37
+ clean.add(path);
38
+ continue;
39
+ }
40
+ if (primaryStatus === 'R') {
41
+ if (oldPath) {
42
+ restore.add(oldPath);
43
+ }
44
+ if (file.staged) {
45
+ unstageThenRemove.add(path);
46
+ }
47
+ clean.add(path);
48
+ continue;
49
+ }
50
+ if (primaryStatus === 'C' || primaryStatus === 'A') {
51
+ if (file.staged) {
52
+ unstageThenRemove.add(path);
53
+ }
54
+ clean.add(path);
55
+ continue;
56
+ }
57
+ restore.add(path);
58
+ }
59
+ return {
60
+ restore: Array.from(restore),
61
+ unstageThenRemove: Array.from(unstageThenRemove),
62
+ clean: Array.from(clean),
63
+ };
64
+ }
6
65
  /** Implements the Git-backed `vcs.*` delegate surface for the SDK runtime. */
7
66
  export class GitVcsDelegates extends VcsDelegateBase {
8
67
  /** Returns the required repository worktree path for a session id. */
@@ -45,7 +104,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
45
104
  throw pluginError('vcs-clone-invalid-args', 'url and dest are required');
46
105
  }
47
106
  const git = this.deps.createGitCommand(process.cwd());
48
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
107
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
49
108
  const lines = `${output.stdout}\n${output.stderr}`
50
109
  .split(/\r?\n/g)
51
110
  .map((line) => line.trim())
@@ -159,15 +218,13 @@ export class GitVcsDelegates extends VcsDelegateBase {
159
218
  }
160
219
  commit(params, _context) {
161
220
  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();
221
+ git.commit(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email), asStringArray(params.paths));
222
+ return git.currentHead();
165
223
  }
166
224
  commitIndex(params, _context) {
167
225
  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();
226
+ git.commitIndex(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email));
227
+ return git.currentHead();
171
228
  }
172
229
  getStatusSummary(params, _context) {
173
230
  const git = this.requireGit(params.session_id);
@@ -184,8 +241,8 @@ export class GitVcsDelegates extends VcsDelegateBase {
184
241
  branch: asTrimmedString(query.rev) || undefined,
185
242
  skip: asNumber(query.skip, 0) || undefined,
186
243
  limit: asNumber(query.limit, 0),
187
- topo_order: query.topo_order ?? undefined,
188
- include_merges: query.include_merges ?? undefined,
244
+ topo_order: asOptionalBoolean(query.topo_order),
245
+ include_merges: asOptionalBoolean(query.include_merges),
189
246
  author_contains: asTrimmedString(query.author_contains) || undefined,
190
247
  since_utc: asTrimmedString(query.since_utc) || undefined,
191
248
  until_utc: asTrimmedString(query.until_utc) || undefined,
@@ -225,13 +282,42 @@ export class GitVcsDelegates extends VcsDelegateBase {
225
282
  git.stagePatch(asString(params.patch));
226
283
  return null;
227
284
  }
285
+ stagePaths(params, _context) {
286
+ const git = this.requireGit(params.session_id);
287
+ git.stagePaths(asStringArray(params.paths));
288
+ return null;
289
+ }
228
290
  discardPaths(params, _context) {
229
291
  const git = this.requireGit(params.session_id);
230
292
  const paths = asStringArray(params.paths);
231
293
  if (paths.length === 0) {
232
294
  return null;
233
295
  }
234
- git.runChecked(['checkout', '--', ...paths], 'git-discard-paths-failed');
296
+ const status = git.runChecked(['status', '--porcelain=1', '-z', '-uall', '--', ...paths], 'git-discard-paths-failed');
297
+ const discardPlan = planDiscardPaths(status.stdout);
298
+ let failure;
299
+ if (discardPlan.unstageThenRemove.length > 0) {
300
+ try {
301
+ git.runChecked(['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove], 'git-discard-paths-failed');
302
+ }
303
+ catch (error) {
304
+ failure = error;
305
+ }
306
+ }
307
+ if (!failure && discardPlan.restore.length > 0) {
308
+ try {
309
+ git.runChecked(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore], 'git-discard-paths-failed');
310
+ }
311
+ catch (error) {
312
+ failure = error;
313
+ }
314
+ }
315
+ if (!failure && discardPlan.clean.length > 0) {
316
+ git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
317
+ }
318
+ if (failure) {
319
+ throw failure;
320
+ }
235
321
  return null;
236
322
  }
237
323
  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' });