@openvcs/git-plugin 0.1.0-nightly.20260421.19 → 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/README.md CHANGED
@@ -65,11 +65,13 @@ The npm package can be consumed from prerelease channels published by CI:
65
65
 
66
66
  - `latest`: stable releases
67
67
  - `beta`: builds from the `Beta` branch
68
+ - `edge`: working builds from `Dev` push commits
68
69
  - `nightly`: scheduled builds from `Dev` when there are changes since the last nightly
69
70
 
70
71
  Examples:
71
72
 
72
73
  ```bash
74
+ npm install @openvcs/git-plugin@edge
73
75
  npm install @openvcs/git-plugin@beta
74
76
  npm install @openvcs/git-plugin@nightly
75
77
  ```
package/bin/git.js CHANGED
@@ -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
  }
@@ -371,8 +386,11 @@ export class GitCommand {
371
386
  hashObject(content) {
372
387
  return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
373
388
  }
389
+ /** Stages a textual patch into the index without requiring worktree/index parity. */
374
390
  stagePatch(patch) {
375
- 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
+ });
376
394
  }
377
395
  applyReversePatch(patch) {
378
396
  this.runChecked(['apply', '-R', patch], 'git-apply-reverse-failed');
@@ -383,9 +401,10 @@ export class GitCommand {
383
401
  resetSoftTo(ref) {
384
402
  this.runChecked(['reset', '--soft', ref], 'git-reset-soft-failed');
385
403
  }
404
+ /** Reads the effective Git commit identity from config. */
386
405
  getIdentity() {
387
- const nameResult = this.run(['config', '--local', 'user.name']);
388
- 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']);
389
408
  if (nameResult.status !== 0 || emailResult.status !== 0) {
390
409
  return null;
391
410
  }
@@ -394,6 +413,7 @@ export class GitCommand {
394
413
  email: emailResult.stdout.trim(),
395
414
  };
396
415
  }
416
+ /** Stores repository-local commit identity in Git config. */
397
417
  setIdentityLocal(name, email) {
398
418
  this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
399
419
  this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
@@ -3,6 +3,10 @@
3
3
  import { VcsDelegateBase, pluginError, } from '@openvcs/sdk/runtime';
4
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
+ }
6
10
  /** Reduces a file status string to the primary status code needed for discard routing. */
7
11
  function getPrimaryDiscardStatus(status) {
8
12
  const normalized = asTrimmedString(status);
@@ -214,15 +218,13 @@ export class GitVcsDelegates extends VcsDelegateBase {
214
218
  }
215
219
  commit(params, _context) {
216
220
  const git = this.requireGit(params.session_id);
217
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
218
- git.commit(asTrimmedString(params.message));
219
- return result.stdout.trim();
221
+ git.commit(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email), asStringArray(params.paths));
222
+ return git.currentHead();
220
223
  }
221
224
  commitIndex(params, _context) {
222
225
  const git = this.requireGit(params.session_id);
223
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
224
- git.commitIndex(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email), asStringArray(params.paths));
225
- return result.stdout.trim();
226
+ git.commitIndex(asTrimmedString(params.message), asTrimmedString(params.name), asTrimmedString(params.email));
227
+ return git.currentHead();
226
228
  }
227
229
  getStatusSummary(params, _context) {
228
230
  const git = this.requireGit(params.session_id);
@@ -239,8 +241,8 @@ export class GitVcsDelegates extends VcsDelegateBase {
239
241
  branch: asTrimmedString(query.rev) || undefined,
240
242
  skip: asNumber(query.skip, 0) || undefined,
241
243
  limit: asNumber(query.limit, 0),
242
- topo_order: query.topo_order ?? undefined,
243
- include_merges: query.include_merges ?? undefined,
244
+ topo_order: asOptionalBoolean(query.topo_order),
245
+ include_merges: asOptionalBoolean(query.include_merges),
244
246
  author_contains: asTrimmedString(query.author_contains) || undefined,
245
247
  since_utc: asTrimmedString(query.since_utc) || undefined,
246
248
  until_utc: asTrimmedString(query.until_utc) || undefined,
@@ -280,6 +282,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
280
282
  git.stagePatch(asString(params.patch));
281
283
  return null;
282
284
  }
285
+ stagePaths(params, _context) {
286
+ const git = this.requireGit(params.session_id);
287
+ git.stagePaths(asStringArray(params.paths));
288
+ return null;
289
+ }
283
290
  discardPaths(params, _context) {
284
291
  const git = this.requireGit(params.session_id);
285
292
  const paths = asStringArray(params.paths);
@@ -288,15 +295,29 @@ export class GitVcsDelegates extends VcsDelegateBase {
288
295
  }
289
296
  const status = git.runChecked(['status', '--porcelain=1', '-z', '-uall', '--', ...paths], 'git-discard-paths-failed');
290
297
  const discardPlan = planDiscardPaths(status.stdout);
298
+ let failure;
291
299
  if (discardPlan.unstageThenRemove.length > 0) {
292
- git.runChecked(['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove], 'git-discard-paths-failed');
300
+ try {
301
+ git.runChecked(['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove], 'git-discard-paths-failed');
302
+ }
303
+ catch (error) {
304
+ failure = error;
305
+ }
293
306
  }
294
- if (discardPlan.restore.length > 0) {
295
- git.runChecked(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore], 'git-discard-paths-failed');
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
+ }
296
314
  }
297
- if (discardPlan.clean.length > 0) {
315
+ if (!failure && discardPlan.clean.length > 0) {
298
316
  git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
299
317
  }
318
+ if (failure) {
319
+ throw failure;
320
+ }
300
321
  return null;
301
322
  }
302
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' });
package/bin/submodules.js CHANGED
@@ -18,7 +18,7 @@ function payloadString(payload, key) {
18
18
  return String(payload[key] ?? '').trim();
19
19
  }
20
20
  /** Builds one modal row for a submodule entry. */
21
- function buildSubmoduleRow(entry) {
21
+ export function buildSubmoduleRow(entry) {
22
22
  const metaBits = [entry.commit ? `commit ${entry.commit}` : '', entry.branch ? `branch ${entry.branch}` : '']
23
23
  .map((part) => String(part || '').trim())
24
24
  .filter(Boolean);
@@ -51,12 +51,44 @@ function buildSubmoduleRow(entry) {
51
51
  ],
52
52
  };
53
53
  }
54
+ /** Builds an error fallback modal with a descriptive message. */
55
+ function buildErrorModal(message) {
56
+ return new ModalBuilder('Error').text(message).text('Please try again or check the Git repository state.');
57
+ }
58
+ /** Opens a fallback modal for a submodule UI failure and preserves the original error on fallback failure. */
59
+ export async function handleSubmoduleModalError(prefix, error, openFallback = (message) => buildErrorModal(message).open()) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ const fullMessage = `${prefix}: ${message}`;
62
+ console.error(`Git submodules: ${fullMessage}`);
63
+ try {
64
+ return await openFallback(fullMessage);
65
+ }
66
+ catch {
67
+ throw error;
68
+ }
69
+ }
54
70
  /** Builds and opens the submodule manager modal. */
55
71
  async function openSubmodulesModal() {
56
72
  const git = createGitCommand();
57
- const entries = git.listSubmodules();
73
+ let entries;
74
+ try {
75
+ entries = git.listSubmodules();
76
+ }
77
+ catch (error) {
78
+ return handleSubmoduleModalError('failed to list submodules', error);
79
+ }
58
80
  console.log('Git submodules: building modal', { count: entries.length });
59
- const modal = new ModalBuilder('Manage Submodules')
81
+ const modal = buildSubmodulesModal(entries);
82
+ try {
83
+ return await modal.open();
84
+ }
85
+ catch (error) {
86
+ return handleSubmoduleModalError('failed to open modal', error);
87
+ }
88
+ }
89
+ /** Builds the submodule manager modal with given entries. */
90
+ function buildSubmodulesModal(entries) {
91
+ return new ModalBuilder('Manage Submodules')
60
92
  .text('Review, add, update, sync, and remove submodules without leaving Git.')
61
93
  .text('Use Update Remote to follow each submodule branch configured in .gitmodules.')
62
94
  .separator()
@@ -109,7 +141,6 @@ async function openSubmodulesModal() {
109
141
  emptyText: 'This repository has no submodules yet.',
110
142
  items: entries.map((entry) => buildSubmoduleRow(entry)),
111
143
  });
112
- return modal.open();
113
144
  }
114
145
  /** Builds and opens the remove confirmation modal for one submodule. */
115
146
  async function openRemoveConfirmationModal(path, name) {
@@ -129,7 +160,12 @@ async function openRemoveConfirmationModal(path, name) {
129
160
  payload: { path: submodulePath, name: String(name || '').trim() || undefined },
130
161
  },
131
162
  ], { gap: '.5rem', align: 'centered', wrap: true });
132
- return modal.open();
163
+ try {
164
+ return await modal.open();
165
+ }
166
+ catch (error) {
167
+ return handleSubmoduleModalError('failed to open remove confirmation modal', error);
168
+ }
133
169
  }
134
170
  /** Removes one submodule and refreshes the toolkit modal. */
135
171
  async function removeSubmodule(payload) {
@@ -200,7 +236,7 @@ async function syncAllSubmodules() {
200
236
  }
201
237
  /** Registers the Git submodule toolkit menu and action handlers. */
202
238
  export function registerSubmoduleToolkit() {
203
- const repoMenu = getOrCreateMenu('repository', 'Repository');
239
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
204
240
  repoMenu?.addItem({ label: 'Submodules', action: 'repo-submodules' });
205
241
  registerAction('repo-submodules', async () => {
206
242
  console.log('Git submodules: repo-submodules action invoked');
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openvcs/git-plugin",
3
- "version": "0.1.0-nightly.20260421.19",
3
+ "version": "0.2.0-beta.40",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/Open-VCS/OpenVCS-Plugin-Git"
9
+ "url": "git+https://github.com/Open-VCS/OpenVCS-Plugin-Git.git"
10
10
  },
11
11
  "bugs": {
12
12
  "url": "https://github.com/Open-VCS/OpenVCS-Plugin-Git/issues"
@@ -18,7 +18,7 @@
18
18
  "openvcs": {
19
19
  "id": "openvcs.git",
20
20
  "name": "Git",
21
- "version": "0.1.0-nightly.20260421.19",
21
+ "version": "0.2.0-beta.40",
22
22
  "author": "OpenVCS Contributors",
23
23
  "description": "Git VCS backend plugin for OpenVCS",
24
24
  "default_enabled": true,
package/src/git.ts CHANGED
@@ -274,31 +274,49 @@ export class GitCommand {
274
274
  return this.runChecked(args, 'git-pull-failed');
275
275
  }
276
276
 
277
- commit(message: string): GitCommandResult {
278
- return this.runChecked(['commit', '-m', message], 'git-commit-failed');
277
+ /** Returns the current HEAD commit id. */
278
+ currentHead(): string {
279
+ return this.runChecked(['rev-parse', 'HEAD'], 'git-head-failed').stdout.trim();
279
280
  }
280
281
 
281
- commitIndex(message?: string, name?: string, email?: string, paths?: string[]): GitCommandResult {
282
- const args = ['commit'];
283
-
284
- if (paths && paths.length > 0) {
285
- args.push('--', ...paths);
286
- } else {
287
- args.push('-a');
288
- }
289
-
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 {
290
298
  const commitMessage = message || 'Stage changes';
291
-
299
+
292
300
  const execArgs = [
293
301
  ...(name ? ['-c', `user.name=${name}`] : []),
294
302
  ...(email ? ['-c', `user.email=${email}`] : []),
295
- ...args,
296
- '-m', commitMessage,
303
+ 'commit',
304
+ '-m',
305
+ commitMessage,
297
306
  ];
298
-
307
+
299
308
  return this.runChecked(execArgs, 'git-commit-failed');
300
309
  }
301
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
+
302
320
  listCommits(options: ListCommitsOptions = {}): { commits: CommitEntry[]; exitCode: number } {
303
321
  const args = ['log', '--all'];
304
322
 
@@ -330,7 +348,7 @@ export class GitCommand {
330
348
  args.push(`--until=${options.until_utc}`);
331
349
  }
332
350
 
333
- args.push('--pretty=format:%H%f%x00%aN%x00%aI%x00%P%x1e');
351
+ args.push('--pretty=format:%H%x00%s%x00%aN%x00%aI%x00%P%x1e');
334
352
 
335
353
  if (options.branch) {
336
354
  args.push(options.branch);
@@ -525,8 +543,11 @@ export class GitCommand {
525
543
  return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
526
544
  }
527
545
 
546
+ /** Stages a textual patch into the index without requiring worktree/index parity. */
528
547
  stagePatch(patch: string): void {
529
- this.runChecked(['apply', '--3way', '--index'], 'git-stage-patch-failed', { stdin: patch });
548
+ this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
549
+ stdin: patch,
550
+ });
530
551
  }
531
552
 
532
553
  applyReversePatch(patch: string): void {
@@ -541,9 +562,10 @@ export class GitCommand {
541
562
  this.runChecked(['reset', '--soft', ref], 'git-reset-soft-failed');
542
563
  }
543
564
 
565
+ /** Reads the effective Git commit identity from config. */
544
566
  getIdentity(): { name: string; email: string } | null {
545
- const nameResult = this.run(['config', '--local', 'user.name']);
546
- const emailResult = this.run(['config', '--local', 'user.email']);
567
+ const nameResult = this.run(['config', '--get', 'user.name']);
568
+ const emailResult = this.run(['config', '--get', 'user.email']);
547
569
 
548
570
  if (nameResult.status !== 0 || emailResult.status !== 0) {
549
571
  return null;
@@ -555,6 +577,7 @@ export class GitCommand {
555
577
  };
556
578
  }
557
579
 
580
+ /** Stores repository-local commit identity in Git config. */
558
581
  setIdentityLocal(name: string, email: string): void {
559
582
  this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
560
583
  this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
@@ -30,6 +30,11 @@ export interface DiscardPathPlan {
30
30
  clean: string[];
31
31
  }
32
32
 
33
+ /** Returns an optional boolean only when the input is already a boolean. */
34
+ function asOptionalBoolean(value: unknown): boolean | undefined {
35
+ return typeof value === 'boolean' ? value : undefined;
36
+ }
37
+
33
38
  /** Reduces a file status string to the primary status code needed for discard routing. */
34
39
  function getPrimaryDiscardStatus(status: string): string {
35
40
  const normalized = asTrimmedString(status);
@@ -348,9 +353,13 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
348
353
  _context: PluginRuntimeContext,
349
354
  ): string {
350
355
  const git = this.requireGit(params.session_id);
351
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
352
- git.commit(asTrimmedString(params.message));
353
- return result.stdout.trim();
356
+ git.commit(
357
+ asTrimmedString(params.message),
358
+ asTrimmedString(params.name),
359
+ asTrimmedString(params.email),
360
+ asStringArray(params.paths),
361
+ );
362
+ return git.currentHead();
354
363
  }
355
364
 
356
365
  override commitIndex(
@@ -358,14 +367,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
358
367
  _context: PluginRuntimeContext,
359
368
  ): string {
360
369
  const git = this.requireGit(params.session_id);
361
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
362
370
  git.commitIndex(
363
371
  asTrimmedString(params.message),
364
372
  asTrimmedString(params.name),
365
373
  asTrimmedString(params.email),
366
- asStringArray(params.paths),
367
374
  );
368
- return result.stdout.trim();
375
+ return git.currentHead();
369
376
  }
370
377
 
371
378
  override getStatusSummary(
@@ -394,8 +401,8 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
394
401
  branch: asTrimmedString(query.rev) || undefined,
395
402
  skip: asNumber(query.skip, 0) || undefined,
396
403
  limit: asNumber(query.limit, 0),
397
- topo_order: query.topo_order as boolean ?? undefined,
398
- include_merges: query.include_merges as boolean ?? undefined,
404
+ topo_order: asOptionalBoolean(query.topo_order),
405
+ include_merges: asOptionalBoolean(query.include_merges),
399
406
  author_contains: asTrimmedString(query.author_contains) || undefined,
400
407
  since_utc: asTrimmedString(query.since_utc) || undefined,
401
408
  until_utc: asTrimmedString(query.until_utc) || undefined,
@@ -463,6 +470,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
463
470
  return null;
464
471
  }
465
472
 
473
+ override stagePaths(
474
+ params: OpenVcs.VcsStagePathsParams,
475
+ _context: PluginRuntimeContext,
476
+ ): null {
477
+ const git = this.requireGit(params.session_id);
478
+ git.stagePaths(asStringArray(params.paths));
479
+ return null;
480
+ }
481
+
466
482
  override discardPaths(
467
483
  params: OpenVcs.VcsDiscardPathsParams,
468
484
  _context: PluginRuntimeContext,
@@ -479,23 +495,38 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
479
495
  );
480
496
  const discardPlan = planDiscardPaths(status.stdout);
481
497
 
498
+ let failure: unknown;
499
+
482
500
  if (discardPlan.unstageThenRemove.length > 0) {
483
- git.runChecked(
484
- ['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove],
485
- 'git-discard-paths-failed',
486
- );
501
+ try {
502
+ git.runChecked(
503
+ ['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove],
504
+ 'git-discard-paths-failed',
505
+ );
506
+ } catch (error) {
507
+ failure = error;
508
+ }
487
509
  }
488
510
 
489
- if (discardPlan.restore.length > 0) {
490
- git.runChecked(
491
- ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore],
492
- 'git-discard-paths-failed',
493
- );
511
+ if (!failure && discardPlan.restore.length > 0) {
512
+ try {
513
+ git.runChecked(
514
+ ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...discardPlan.restore],
515
+ 'git-discard-paths-failed',
516
+ );
517
+ } catch (error) {
518
+ failure = error;
519
+ }
494
520
  }
495
521
 
496
- if (discardPlan.clean.length > 0) {
522
+ if (!failure && discardPlan.clean.length > 0) {
497
523
  git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
498
524
  }
525
+
526
+ if (failure) {
527
+ throw failure;
528
+ }
529
+
499
530
  return null;
500
531
  }
501
532
 
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
@@ -26,7 +26,7 @@ function payloadString(payload: ModalActionPayload, key: string): string {
26
26
  }
27
27
 
28
28
  /** Builds one modal row for a submodule entry. */
29
- function buildSubmoduleRow(entry: SubmoduleEntry) {
29
+ export function buildSubmoduleRow(entry: SubmoduleEntry) {
30
30
  const metaBits = [entry.commit ? `commit ${entry.commit}` : '', entry.branch ? `branch ${entry.branch}` : '']
31
31
  .map((part) => String(part || '').trim())
32
32
  .filter(Boolean);
@@ -62,13 +62,52 @@ function buildSubmoduleRow(entry: SubmoduleEntry) {
62
62
  };
63
63
  }
64
64
 
65
+ /** Builds an error fallback modal with a descriptive message. */
66
+ function buildErrorModal(message: string): ModalBuilder {
67
+ return new ModalBuilder('Error').text(message).text('Please try again or check the Git repository state.');
68
+ }
69
+
70
+ /** Opens a fallback modal for a submodule UI failure and preserves the original error on fallback failure. */
71
+ export async function handleSubmoduleModalError(
72
+ prefix: string,
73
+ error: unknown,
74
+ openFallback: (message: string) => Promise<unknown> = (message) => buildErrorModal(message).open(),
75
+ ): Promise<unknown> {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ const fullMessage = `${prefix}: ${message}`;
78
+ console.error(`Git submodules: ${fullMessage}`);
79
+
80
+ try {
81
+ return await openFallback(fullMessage);
82
+ } catch {
83
+ throw error;
84
+ }
85
+ }
86
+
65
87
  /** Builds and opens the submodule manager modal. */
66
88
  async function openSubmodulesModal(): Promise<unknown> {
67
89
  const git = createGitCommand();
68
- const entries = git.listSubmodules();
90
+
91
+ let entries: SubmoduleEntry[];
92
+ try {
93
+ entries = git.listSubmodules();
94
+ } catch (error) {
95
+ return handleSubmoduleModalError('failed to list submodules', error);
96
+ }
97
+
69
98
  console.log('Git submodules: building modal', { count: entries.length });
70
99
 
71
- const modal = new ModalBuilder('Manage Submodules')
100
+ const modal = buildSubmodulesModal(entries);
101
+ try {
102
+ return await modal.open();
103
+ } catch (error) {
104
+ return handleSubmoduleModalError('failed to open modal', error);
105
+ }
106
+ }
107
+
108
+ /** Builds the submodule manager modal with given entries. */
109
+ function buildSubmodulesModal(entries: SubmoduleEntry[]): ModalBuilder {
110
+ return new ModalBuilder('Manage Submodules')
72
111
  .text('Review, add, update, sync, and remove submodules without leaving Git.')
73
112
  .text('Use Update Remote to follow each submodule branch configured in .gitmodules.')
74
113
  .separator()
@@ -130,14 +169,13 @@ async function openSubmodulesModal(): Promise<unknown> {
130
169
  emptyText: 'This repository has no submodules yet.',
131
170
  items: entries.map((entry) => buildSubmoduleRow(entry)),
132
171
  });
133
-
134
- return modal.open();
135
172
  }
136
173
 
137
174
  /** Builds and opens the remove confirmation modal for one submodule. */
138
175
  async function openRemoveConfirmationModal(path: string, name?: string): Promise<unknown> {
139
176
  const submodulePath = String(path || '').trim();
140
177
  if (!submodulePath) return;
178
+
141
179
  console.log('Git submodules: building remove confirmation', { path: submodulePath, name });
142
180
 
143
181
  const modal = new ModalBuilder('Confirm Submodule Removal')
@@ -156,7 +194,11 @@ async function openRemoveConfirmationModal(path: string, name?: string): Promise
156
194
  { gap: '.5rem', align: 'centered', wrap: true },
157
195
  );
158
196
 
159
- return modal.open();
197
+ try {
198
+ return await modal.open();
199
+ } catch (error) {
200
+ return handleSubmoduleModalError('failed to open remove confirmation modal', error);
201
+ }
160
202
  }
161
203
 
162
204
  /** Removes one submodule and refreshes the toolkit modal. */
@@ -234,7 +276,7 @@ async function syncAllSubmodules(): Promise<void> {
234
276
 
235
277
  /** Registers the Git submodule toolkit menu and action handlers. */
236
278
  export function registerSubmoduleToolkit(): void {
237
- const repoMenu = getOrCreateMenu('repository', 'Repository');
279
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
238
280
  repoMenu?.addItem({ label: 'Submodules', action: 'repo-submodules' });
239
281
 
240
282
  registerAction('repo-submodules', async () => {