@openvcs/git-plugin 0.1.0 → 0.2.0-edge.20260426.39

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.
@@ -14,10 +14,93 @@ 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
+ /** 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
+
38
+ /** Reduces a file status string to the primary status code needed for discard routing. */
39
+ function getPrimaryDiscardStatus(status: string): string {
40
+ const normalized = asTrimmedString(status);
41
+ if (!normalized) {
42
+ return 'M';
43
+ }
44
+
45
+ for (const candidate of ['?', 'R', 'C', 'A', 'D', 'U', 'T', 'S', 'M']) {
46
+ if (normalized.includes(candidate)) {
47
+ return candidate;
48
+ }
49
+ }
50
+
51
+ return normalized[0] ?? 'M';
52
+ }
53
+
54
+ /** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
55
+ export function planDiscardPaths(statusOutput: string): DiscardPathPlan {
56
+ const restore = new Set<string>();
57
+ const unstageThenRemove = new Set<string>();
58
+ const clean = new Set<string>();
59
+ const parsed = parseStatusOutput(statusOutput);
60
+
61
+ for (const file of parsed.payload.files) {
62
+ const path = asTrimmedString(file.path);
63
+ const oldPath = asTrimmedString(file.old_path);
64
+ const primaryStatus = getPrimaryDiscardStatus(file.status);
65
+
66
+ if (!path) {
67
+ continue;
68
+ }
69
+
70
+ if (primaryStatus === '?') {
71
+ clean.add(path);
72
+ continue;
73
+ }
74
+
75
+ if (primaryStatus === 'R') {
76
+ if (oldPath) {
77
+ restore.add(oldPath);
78
+ }
79
+ if (file.staged) {
80
+ unstageThenRemove.add(path);
81
+ }
82
+ clean.add(path);
83
+ continue;
84
+ }
85
+
86
+ if (primaryStatus === 'C' || primaryStatus === 'A') {
87
+ if (file.staged) {
88
+ unstageThenRemove.add(path);
89
+ }
90
+ clean.add(path);
91
+ continue;
92
+ }
93
+
94
+ restore.add(path);
95
+ }
96
+
97
+ return {
98
+ restore: Array.from(restore),
99
+ unstageThenRemove: Array.from(unstageThenRemove),
100
+ clean: Array.from(clean),
101
+ };
102
+ }
103
+
21
104
  /** Describes the Git runtime services consumed by the VCS delegates. */
22
105
  export interface GitRuntimeDependencies {
23
106
  /** Allocates a new repository session and returns its id. */
@@ -92,7 +175,7 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
92
175
  }
93
176
 
94
177
  const git = this.deps.createGitCommand(process.cwd());
95
- const output = git.runChecked(['clone', url, destination], 'vcs-clone-failed');
178
+ const output = git.runChecked(buildCloneArgs({ url, dest: destination }), 'vcs-clone-failed');
96
179
  const lines = `${output.stdout}\n${output.stderr}`
97
180
  .split(/\r?\n/g)
98
181
  .map((line) => line.trim())
@@ -270,9 +353,13 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
270
353
  _context: PluginRuntimeContext,
271
354
  ): string {
272
355
  const git = this.requireGit(params.session_id);
273
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
274
- git.commit(asTrimmedString(params.message));
275
- return result.stdout.trim();
356
+ git.commit(
357
+ asTrimmedString(params.message),
358
+ asTrimmedString(params.name),
359
+ asTrimmedString(params.email),
360
+ asStringArray(params.paths),
361
+ );
362
+ return git.currentHead();
276
363
  }
277
364
 
278
365
  override commitIndex(
@@ -280,14 +367,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
280
367
  _context: PluginRuntimeContext,
281
368
  ): string {
282
369
  const git = this.requireGit(params.session_id);
283
- const result = git.runChecked(['rev-parse', 'HEAD'], 'git-commit-failed');
284
370
  git.commitIndex(
285
371
  asTrimmedString(params.message),
286
372
  asTrimmedString(params.name),
287
373
  asTrimmedString(params.email),
288
- asStringArray(params.paths),
289
374
  );
290
- return result.stdout.trim();
375
+ return git.currentHead();
291
376
  }
292
377
 
293
378
  override getStatusSummary(
@@ -316,8 +401,8 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
316
401
  branch: asTrimmedString(query.rev) || undefined,
317
402
  skip: asNumber(query.skip, 0) || undefined,
318
403
  limit: asNumber(query.limit, 0),
319
- topo_order: query.topo_order as boolean ?? undefined,
320
- include_merges: query.include_merges as boolean ?? undefined,
404
+ topo_order: asOptionalBoolean(query.topo_order),
405
+ include_merges: asOptionalBoolean(query.include_merges),
321
406
  author_contains: asTrimmedString(query.author_contains) || undefined,
322
407
  since_utc: asTrimmedString(query.since_utc) || undefined,
323
408
  until_utc: asTrimmedString(query.until_utc) || undefined,
@@ -385,6 +470,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
385
470
  return null;
386
471
  }
387
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
+
388
482
  override discardPaths(
389
483
  params: OpenVcs.VcsDiscardPathsParams,
390
484
  _context: PluginRuntimeContext,
@@ -395,7 +489,44 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
395
489
  return null;
396
490
  }
397
491
 
398
- git.runChecked(['checkout', '--', ...paths], 'git-discard-paths-failed');
492
+ const status = git.runChecked(
493
+ ['status', '--porcelain=1', '-z', '-uall', '--', ...paths],
494
+ 'git-discard-paths-failed',
495
+ );
496
+ const discardPlan = planDiscardPaths(status.stdout);
497
+
498
+ let failure: unknown;
499
+
500
+ if (discardPlan.unstageThenRemove.length > 0) {
501
+ try {
502
+ git.runChecked(
503
+ ['rm', '-f', '--cached', '--', ...discardPlan.unstageThenRemove],
504
+ 'git-discard-paths-failed',
505
+ );
506
+ } catch (error) {
507
+ failure = error;
508
+ }
509
+ }
510
+
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
+ }
520
+ }
521
+
522
+ if (!failure && discardPlan.clean.length > 0) {
523
+ git.runChecked(['clean', '-f', '--', ...discardPlan.clean], 'git-discard-paths-failed');
524
+ }
525
+
526
+ if (failure) {
527
+ throw failure;
528
+ }
529
+
399
530
  return null;
400
531
  }
401
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);
@@ -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,
@@ -56,69 +62,143 @@ function buildSubmoduleRow(entry: SubmoduleEntry) {
56
62
  };
57
63
  }
58
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
+
59
87
  /** Builds and opens the submodule manager modal. */
60
88
  async function openSubmodulesModal(): Promise<unknown> {
61
89
  const git = createGitCommand();
62
- 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
+
63
98
  console.log('Git submodules: building modal', { count: entries.length });
64
99
 
65
- 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')
66
111
  .text('Review, add, update, sync, and remove submodules without leaving Git.')
112
+ .text('Use Update Remote to follow each submodule branch configured in .gitmodules.')
67
113
  .separator()
68
- .input('url', 'Submodule URL', {
69
- kind: 'url',
70
- placeholder: 'https://example.com/repo.git',
71
- })
72
- .input('path', 'Submodule Path', {
73
- placeholder: 'libs/example',
74
- })
75
- .input('name', 'Submodule Name', {
76
- placeholder: 'example',
77
- })
78
- .input('branch', 'Branch (optional)', {
79
- placeholder: 'main',
80
- })
81
- .button('submodules-add', 'Add Submodule', {
82
- variant: 'primary',
83
- align: 'centered',
84
- })
85
- .button('repo-submodules', 'Refresh', {
86
- align: 'centered',
87
- })
88
- .button('submodules-update-all', 'Update All (Recursive)', {
89
- align: 'centered',
90
- })
91
- .button('submodules-sync-all', 'Sync All', {
92
- align: 'centered',
93
- })
114
+ .verticalBox(
115
+ [
116
+ {
117
+ type: 'input' as const,
118
+ id: 'url',
119
+ label: 'Submodule URL',
120
+ kind: 'url' as const,
121
+ placeholder: 'https://example.com/repo.git',
122
+ },
123
+ {
124
+ type: 'grid' as const,
125
+ columns: 'minmax(0, 1fr) minmax(0, 1fr)',
126
+ gap: '.75rem',
127
+ content: [
128
+ {
129
+ type: 'input' as const,
130
+ id: 'path',
131
+ label: 'Submodule Path',
132
+ placeholder: 'libs/example',
133
+ },
134
+ {
135
+ type: 'input' as const,
136
+ id: 'name',
137
+ label: 'Submodule Name',
138
+ placeholder: 'example',
139
+ },
140
+ ],
141
+ },
142
+ {
143
+ type: 'input' as const,
144
+ id: 'branch',
145
+ label: 'Branch (optional)',
146
+ placeholder: 'main',
147
+ },
148
+ ],
149
+ { gap: '1rem' },
150
+ )
151
+ .horizontalBox(
152
+ [
153
+ { type: 'button' as const, id: 'submodules-add', content: 'Add Submodule', variant: 'primary' as const },
154
+ { type: 'button' as const, id: 'repo-submodules', content: 'Refresh' },
155
+ ],
156
+ { gap: '.5rem', align: 'centered', wrap: true },
157
+ )
158
+ .horizontalBox(
159
+ [
160
+ { type: 'button' as const, id: 'submodules-update-all', content: 'Update All (Recursive)' },
161
+ { type: 'button' as const, id: 'submodules-update-all-remote', content: 'Update All From Branches' },
162
+ { type: 'button' as const, id: 'submodules-sync-all', content: 'Sync All' },
163
+ ],
164
+ { gap: '.5rem', align: 'centered', wrap: true },
165
+ )
94
166
  .separator()
95
167
  .list('submodules', {
96
168
  label: 'Submodules',
97
169
  emptyText: 'This repository has no submodules yet.',
98
170
  items: entries.map((entry) => buildSubmoduleRow(entry)),
99
171
  });
100
-
101
- return modal.open();
102
172
  }
103
173
 
104
174
  /** Builds and opens the remove confirmation modal for one submodule. */
105
175
  async function openRemoveConfirmationModal(path: string, name?: string): Promise<unknown> {
106
176
  const submodulePath = String(path || '').trim();
107
177
  if (!submodulePath) return;
178
+
108
179
  console.log('Git submodules: building remove confirmation', { path: submodulePath, name });
109
180
 
110
181
  const modal = new ModalBuilder('Confirm Submodule Removal')
111
182
  .text(`Remove the submodule at ${submodulePath}? This will deinitialize the submodule, remove it from the index, and delete its working tree entry.`)
112
- .button('repo-submodules', 'Back', {
113
- align: 'centered',
114
- })
115
- .button('submodules-remove-confirm', 'Remove Submodule', {
116
- variant: 'danger',
117
- align: 'centered',
118
- payload: { path: submodulePath, name: String(name || '').trim() || undefined },
119
- });
120
-
121
- return modal.open();
183
+ .horizontalBox(
184
+ [
185
+ { type: 'button' as const, id: 'repo-submodules', content: 'Back' },
186
+ {
187
+ type: 'button' as const,
188
+ id: 'submodules-remove-confirm',
189
+ content: 'Remove Submodule',
190
+ variant: 'danger' as const,
191
+ payload: { path: submodulePath, name: String(name || '').trim() || undefined },
192
+ },
193
+ ],
194
+ { gap: '.5rem', align: 'centered', wrap: true },
195
+ );
196
+
197
+ try {
198
+ return await modal.open();
199
+ } catch (error) {
200
+ return handleSubmoduleModalError('failed to open remove confirmation modal', error);
201
+ }
122
202
  }
123
203
 
124
204
  /** Removes one submodule and refreshes the toolkit modal. */
@@ -155,6 +235,15 @@ async function updateSubmodule(payload: ModalActionPayload): Promise<void> {
155
235
  await openSubmodulesModal();
156
236
  }
157
237
 
238
+ /** Updates one submodule from its configured branch and refreshes the toolkit modal. */
239
+ async function updateSubmoduleRemote(payload: ModalActionPayload): Promise<void> {
240
+ const path = payloadString(payload, 'path');
241
+ if (!path) return;
242
+ const git = createGitCommand();
243
+ git.updateSubmoduleRemote(path);
244
+ await openSubmodulesModal();
245
+ }
246
+
158
247
  /** Syncs one submodule and refreshes the toolkit modal. */
159
248
  async function syncSubmodule(payload: ModalActionPayload): Promise<void> {
160
249
  const path = payloadString(payload, 'path');
@@ -171,6 +260,13 @@ async function updateAllSubmodules(): Promise<void> {
171
260
  await openSubmodulesModal();
172
261
  }
173
262
 
263
+ /** Updates all submodules from their configured branches and refreshes the toolkit modal. */
264
+ async function updateAllSubmodulesRemote(): Promise<void> {
265
+ const git = createGitCommand();
266
+ git.updateAllSubmodulesRemote();
267
+ await openSubmodulesModal();
268
+ }
269
+
174
270
  /** Syncs all submodules recursively and refreshes the toolkit modal. */
175
271
  async function syncAllSubmodules(): Promise<void> {
176
272
  const git = createGitCommand();
@@ -180,7 +276,7 @@ async function syncAllSubmodules(): Promise<void> {
180
276
 
181
277
  /** Registers the Git submodule toolkit menu and action handlers. */
182
278
  export function registerSubmoduleToolkit(): void {
183
- const repoMenu = getOrCreateMenu('repository', 'Repository');
279
+ const repoMenu = getOrCreateMenu('repository', 'Repository', { surface: 'menubar' });
184
280
  repoMenu?.addItem({ label: 'Submodules', action: 'repo-submodules' });
185
281
 
186
282
  registerAction('repo-submodules', async () => {
@@ -196,6 +292,10 @@ export function registerSubmoduleToolkit(): void {
196
292
  return updateAllSubmodules();
197
293
  });
198
294
 
295
+ registerAction('submodules-update-all-remote', async () => {
296
+ return updateAllSubmodulesRemote();
297
+ });
298
+
199
299
  registerAction('submodules-sync-all', async () => {
200
300
  return syncAllSubmodules();
201
301
  });
@@ -204,6 +304,10 @@ export function registerSubmoduleToolkit(): void {
204
304
  return updateSubmodule(asPayload(payload));
205
305
  });
206
306
 
307
+ registerAction('submodules-update-remote', async (payload?: unknown) => {
308
+ return updateSubmoduleRemote(asPayload(payload));
309
+ });
310
+
207
311
  registerAction('submodules-sync', async (payload?: unknown) => {
208
312
  return syncSubmodule(asPayload(payload));
209
313
  });