@openvcs/git-plugin 0.1.0

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/src/git.ts ADDED
@@ -0,0 +1,615 @@
1
+ // Copyright © 2025-2026 OpenVCS Contributors
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { spawnSync } from 'node:child_process';
5
+ import { rmSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { pluginError } from '@openvcs/sdk/runtime';
9
+ import type {
10
+ CommitEntry,
11
+ StatusFileEntry,
12
+ StatusParseResult,
13
+ StatusSummary,
14
+ } from '@openvcs/sdk/types';
15
+ import type { GitCommandResult, RunGitOptions } from './plugin-types.js';
16
+ import {
17
+ asString,
18
+ buildFetchArgs,
19
+ buildPullFfOnlyArgs,
20
+ buildPushArgs,
21
+ parseCommits,
22
+ parseStatusOutput,
23
+ } from './plugin-helpers.js';
24
+
25
+ export interface FetchOptions {
26
+ remote?: string;
27
+ refspec?: string;
28
+ opts?: { prune?: boolean };
29
+ }
30
+
31
+ export interface PushOptions {
32
+ remote?: string;
33
+ refspec?: string;
34
+ }
35
+
36
+ export interface PullOptions {
37
+ remote?: string;
38
+ branch?: string;
39
+ }
40
+
41
+ export interface ListCommitsOptions {
42
+ branch?: string;
43
+ skip?: number;
44
+ limit?: number;
45
+ topo_order?: boolean;
46
+ include_merges?: boolean;
47
+ author_contains?: string;
48
+ since_utc?: string;
49
+ until_utc?: string;
50
+ path?: string;
51
+ }
52
+
53
+ export interface BranchParseResult {
54
+ current: string | null;
55
+ branches: Array<{ name: string; current: boolean }>;
56
+ }
57
+
58
+ export interface RemoteParseResult {
59
+ remotes: Array<{ name: string; fetch: string; push: string }>;
60
+ }
61
+
62
+ export interface ConflictDetails {
63
+ path: string;
64
+ ours: string | null;
65
+ theirs: string | null;
66
+ base: string | null;
67
+ binary: boolean;
68
+ lfs_pointer: boolean;
69
+ }
70
+
71
+ export interface StashEntry {
72
+ selector: string;
73
+ msg: string;
74
+ meta: string;
75
+ }
76
+
77
+ export interface SubmoduleEntry {
78
+ path: string;
79
+ name: string;
80
+ url?: string;
81
+ branch?: string;
82
+ commit?: string;
83
+ state: 'clean' | 'dirty' | 'uninitialized' | 'conflicted';
84
+ }
85
+
86
+ export class GitCommand {
87
+ private readonly cwd: string;
88
+
89
+ constructor(cwd: string) {
90
+ this.cwd = cwd;
91
+ }
92
+
93
+ run(args: string[], options: RunGitOptions = {}): GitCommandResult {
94
+ const result = spawnSync('git', args, {
95
+ cwd: this.cwd,
96
+ input: typeof options.stdin === 'string' ? options.stdin : undefined,
97
+ encoding: 'utf8',
98
+ maxBuffer: 16 * 1024 * 1024,
99
+ });
100
+
101
+ if (result.status === null) {
102
+ const signal = result.signal ?? 'unknown';
103
+ console.warn(`git process killed/crashed (signal: ${signal}) in ${this.cwd}: ${args.join(' ')}`);
104
+ return {
105
+ status: -2,
106
+ stdout: asString(result.stdout),
107
+ stderr: asString(result.stderr) || `Process terminated by signal: ${signal}`,
108
+ };
109
+ }
110
+
111
+ return {
112
+ status: result.status,
113
+ stdout: asString(result.stdout),
114
+ stderr: asString(result.stderr),
115
+ };
116
+ }
117
+
118
+ runChecked(args: string[], errorCode: string, options: RunGitOptions = {}): GitCommandResult {
119
+ const output = this.run(args, options);
120
+
121
+ if (output.status !== 0) {
122
+ const exitInfo = output.status === -2 ? ` (signal: ${output.stderr})` : ` (exit code: ${output.status})`;
123
+ const message =
124
+ output.stderr.trim() ||
125
+ output.stdout.trim() ||
126
+ `git ${args.join(' ')}${exitInfo}`;
127
+ throw pluginError(errorCode, message);
128
+ }
129
+
130
+ return output;
131
+ }
132
+
133
+ version(): { result: GitCommandResult; version: string; major: number; minor: number } {
134
+ const result = this.run(['--version']);
135
+ const versionMatch = result.stdout.match(/git version (\d+)\.(\d+)/);
136
+ if (!versionMatch) {
137
+ throw new Error(`Unable to parse Git version: ${result.stdout.trim()}`);
138
+ }
139
+ const major = parseInt(versionMatch[1], 10);
140
+ const minor = parseInt(versionMatch[2], 10);
141
+ return {
142
+ result,
143
+ version: versionMatch[0],
144
+ major,
145
+ minor,
146
+ };
147
+ }
148
+
149
+ status(): StatusParseResult & { exitCode: number } {
150
+ const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
151
+ const parsed = parseStatusOutput(result.stdout);
152
+ return { ...parsed, exitCode: result.status };
153
+ }
154
+
155
+ currentBranch(): string {
156
+ const result = this.runChecked(['rev-parse', '--abbrev-ref', 'HEAD'], 'git-branch-failed');
157
+ return result.stdout.trim();
158
+ }
159
+
160
+ listBranches(): BranchParseResult {
161
+ const result = this.run(['branch', '-a', '--format=%(refname:short)%(if)%(HEAD)%(then)*%(end)']);
162
+ const current = this.currentBranch();
163
+ const branches: Array<{ name: string; current: boolean }> = [];
164
+
165
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
166
+ const isCurrent = line.endsWith('*');
167
+ const baseLine = isCurrent ? line.slice(0, -1) : line;
168
+ const name = baseLine.trim();
169
+ if (name) {
170
+ branches.push({ name, current: name === current || (isCurrent && name === current) });
171
+ }
172
+ }
173
+
174
+ return { current, branches };
175
+ }
176
+
177
+ listLocalBranches(): BranchParseResult {
178
+ const result = this.run(['branch', '--format=%(refname:short)%(if)%(HEAD)%(then)*%(end)']);
179
+ const current = this.currentBranch();
180
+ const branches: Array<{ name: string; current: boolean }> = [];
181
+
182
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
183
+ const trimmed = line.replaceAll('*', '').trim();
184
+ if (trimmed) {
185
+ branches.push({ name: trimmed, current: trimmed === current });
186
+ }
187
+ }
188
+
189
+ return { current, branches };
190
+ }
191
+
192
+ createBranch(name: string): void {
193
+ this.runChecked(['branch', name], 'git-branch-create-failed');
194
+ }
195
+
196
+ checkoutBranch(name: string): void {
197
+ this.runChecked(['checkout', name], 'git-checkout-failed');
198
+ }
199
+
200
+ deleteBranch(name: string): void {
201
+ this.runChecked(['branch', '-d', name], 'git-branch-delete-failed');
202
+ }
203
+
204
+ renameBranch(oldName: string, newName: string): void {
205
+ this.runChecked(['branch', '-m', oldName, newName], 'git-branch-rename-failed');
206
+ }
207
+
208
+ getBranchUpstream(branchName: string): string | null {
209
+ const result = this.run(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]);
210
+ return result.status === 0 ? result.stdout.trim() : null;
211
+ }
212
+
213
+ setBranchUpstream(branchName: string, upstream: string): void {
214
+ this.runChecked(['branch', '--set-upstream-to', upstream, branchName], 'git-branch-upstream-failed');
215
+ }
216
+
217
+ ensureRemote(name: string, url: string): void {
218
+ const probe = this.run(['remote', 'get-url', name]);
219
+ if (probe.status === 0) {
220
+ if (probe.stdout.trim() !== url) {
221
+ this.runChecked(['remote', 'set-url', name, url], 'git-remote-set-url-failed');
222
+ }
223
+ } else {
224
+ this.runChecked(['remote', 'add', name, url], 'git-remote-add-failed');
225
+ }
226
+ }
227
+
228
+ listRemotes(): RemoteParseResult {
229
+ const result = this.runChecked(['remote', '-v'], 'git-remote-list-failed');
230
+ const remotes: Array<{ name: string; fetch: string; push: string }> = [];
231
+ const remoteMap = new Map<string, { fetch: string; push: string }>();
232
+
233
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
234
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
235
+ if (match) {
236
+ const [, name, url, type] = match;
237
+ if (!remoteMap.has(name)) {
238
+ remoteMap.set(name, { fetch: '', push: '' });
239
+ }
240
+ const entry = remoteMap.get(name)!;
241
+ if (type === 'fetch') {
242
+ entry.fetch = url;
243
+ } else if (type === 'push') {
244
+ entry.push = url;
245
+ }
246
+ }
247
+ }
248
+
249
+ remoteMap.forEach((value, key) => {
250
+ remotes.push({ name: key, ...value });
251
+ });
252
+
253
+ return { remotes };
254
+ }
255
+
256
+ removeRemote(name: string): void {
257
+ this.runChecked(['remote', 'remove', name], 'git-remote-remove-failed');
258
+ }
259
+
260
+ fetch(options: FetchOptions = {}): GitCommandResult {
261
+ const args = buildFetchArgs(options as unknown as Record<string, unknown>);
262
+ return this.runChecked(args, 'git-fetch-failed');
263
+ }
264
+
265
+ push(options: PushOptions = {}): GitCommandResult {
266
+ const args = buildPushArgs(options as unknown as Record<string, unknown>);
267
+ return this.runChecked(args, 'git-push-failed');
268
+ }
269
+
270
+ pull(options: PullOptions = {}): GitCommandResult {
271
+ const args = buildPullFfOnlyArgs(options as unknown as Record<string, unknown>);
272
+ return this.runChecked(args, 'git-pull-failed');
273
+ }
274
+
275
+ commit(message: string): GitCommandResult {
276
+ return this.runChecked(['commit', '-m', message], 'git-commit-failed');
277
+ }
278
+
279
+ commitIndex(message?: string, name?: string, email?: string, paths?: string[]): GitCommandResult {
280
+ const args = ['commit'];
281
+
282
+ if (paths && paths.length > 0) {
283
+ args.push('--', ...paths);
284
+ } else {
285
+ args.push('-a');
286
+ }
287
+
288
+ const commitMessage = message || 'Stage changes';
289
+
290
+ const execArgs = [
291
+ ...(name ? ['-c', `user.name=${name}`] : []),
292
+ ...(email ? ['-c', `user.email=${email}`] : []),
293
+ ...args,
294
+ '-m', commitMessage,
295
+ ];
296
+
297
+ return this.runChecked(execArgs, 'git-commit-failed');
298
+ }
299
+
300
+ listCommits(options: ListCommitsOptions = {}): { commits: CommitEntry[]; exitCode: number } {
301
+ const args = ['log', '--all'];
302
+
303
+ if (options.topo_order) {
304
+ args.push('--topo-order');
305
+ }
306
+
307
+ if (options.limit !== undefined) {
308
+ args.push(`-${options.limit}`);
309
+ }
310
+
311
+ if (options.skip !== undefined) {
312
+ args.push(`--skip=${options.skip}`);
313
+ }
314
+
315
+ if (options.include_merges === false) {
316
+ args.push('--no-merges');
317
+ }
318
+
319
+ if (options.author_contains) {
320
+ args.push(`--author=${options.author_contains}`);
321
+ }
322
+
323
+ if (options.since_utc) {
324
+ args.push(`--since=${options.since_utc}`);
325
+ }
326
+
327
+ if (options.until_utc) {
328
+ args.push(`--until=${options.until_utc}`);
329
+ }
330
+
331
+ args.push('--pretty=format:%H%f%x00%aN%x00%aI%x00%P%x1e');
332
+
333
+ if (options.branch) {
334
+ args.push(options.branch);
335
+ }
336
+
337
+ if (options.path) {
338
+ args.push('--', options.path);
339
+ }
340
+
341
+ const result = this.run(args);
342
+ const commits = parseCommits(result.stdout);
343
+ return { commits, exitCode: result.status };
344
+ }
345
+
346
+ listSubmodules(): SubmoduleEntry[] {
347
+ const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
348
+ const configByName = new Map<string, { name: string; path?: string; url?: string; branch?: string }>();
349
+
350
+ if (configResult.status === 0) {
351
+ for (const entry of configResult.stdout.split('\0')) {
352
+ const trimmed = entry.trim();
353
+ if (!trimmed) continue;
354
+
355
+ const splitAt = trimmed.indexOf('\n');
356
+ if (splitAt < 0) continue;
357
+
358
+ const key = trimmed.slice(0, splitAt).trim();
359
+ const value = trimmed.slice(splitAt + 1);
360
+ const match = key.match(/^submodule\.(.+)\.(path|url|branch)$/);
361
+ if (!match) continue;
362
+
363
+ const [, name, field] = match;
364
+ const target = configByName.get(name) || { name };
365
+
366
+ if (field === 'path') target.path = value;
367
+ if (field === 'url') target.url = value;
368
+ if (field === 'branch') target.branch = value;
369
+
370
+ configByName.set(name, target);
371
+ }
372
+ }
373
+
374
+ const configByPath = new Map<string, { name: string; url?: string; branch?: string }>();
375
+ for (const entry of configByName.values()) {
376
+ if (entry.path) {
377
+ configByPath.set(entry.path, entry);
378
+ }
379
+ }
380
+
381
+ const statusResult = this.run(['submodule', 'status', '--recursive']);
382
+ if (statusResult.status !== 0) {
383
+ return [];
384
+ }
385
+
386
+ const stateFor = (marker: string): SubmoduleEntry['state'] => {
387
+ if (marker === '-') return 'uninitialized';
388
+ if (marker === '+') return 'dirty';
389
+ if (marker === 'U') return 'conflicted';
390
+ return 'clean';
391
+ };
392
+
393
+ const entries: SubmoduleEntry[] = [];
394
+ for (const rawLine of statusResult.stdout.split(/\r?\n/g)) {
395
+ const line = rawLine.trim();
396
+ if (!line) continue;
397
+
398
+ const marker = line[0] || ' ';
399
+ const rest = line.slice(1).trim();
400
+ const [commit = '', path = ''] = rest.split(/\s+/);
401
+ if (!path) continue;
402
+
403
+ const config = configByPath.get(path);
404
+ entries.push({
405
+ path,
406
+ name: config?.name || path.split('/').filter(Boolean).at(-1) || path,
407
+ url: config?.url,
408
+ branch: config?.branch,
409
+ commit,
410
+ state: stateFor(marker),
411
+ });
412
+ }
413
+
414
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
415
+ }
416
+
417
+ addSubmodule(url: string, path: string, name?: string, branch?: string): GitCommandResult {
418
+ const args = ['submodule', 'add'];
419
+ if (name) args.push('--name', name);
420
+ if (branch) args.push('--branch', branch);
421
+ args.push(url, path);
422
+ return this.runChecked(args, 'git-submodule-add-failed');
423
+ }
424
+
425
+ updateSubmodule(path: string): GitCommandResult {
426
+ return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
427
+ }
428
+
429
+ updateAllSubmodules(): GitCommandResult {
430
+ return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
431
+ }
432
+
433
+ syncSubmodule(path: string): GitCommandResult {
434
+ return this.runChecked(['submodule', 'sync', '--recursive', '--', path], 'git-submodule-sync-failed');
435
+ }
436
+
437
+ syncAllSubmodules(): GitCommandResult {
438
+ return this.runChecked(['submodule', 'sync', '--recursive'], 'git-submodule-sync-failed');
439
+ }
440
+
441
+ removeSubmodule(path: string): void {
442
+ this.runChecked(['submodule', 'deinit', '-f', '--', path], 'git-submodule-remove-failed');
443
+ this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
444
+
445
+ const modulesPath = join(this.cwd, '.git', 'modules', path);
446
+ try {
447
+ rmSync(modulesPath, { recursive: true, force: true });
448
+ } catch {
449
+ // Best-effort cleanup.
450
+ }
451
+ }
452
+
453
+ diffFile(path: string): string {
454
+ return this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed').stdout;
455
+ }
456
+
457
+ diffCommit(commit: string): string {
458
+ return this.runChecked(['diff', `${commit}^`, commit], 'git-diff-failed').stdout;
459
+ }
460
+
461
+ getConflictDetails(path: string): ConflictDetails {
462
+ const ours = this.run(['show', `:2:${path}`]);
463
+ const theirs = this.run(['show', `:3:${path}`]);
464
+ const base = this.run(['show', `:1:${path}`]);
465
+
466
+ if (ours.status !== 0 || theirs.status !== 0) {
467
+ return { path, ours: null, theirs: null, base: null, binary: false, lfs_pointer: false };
468
+ }
469
+
470
+ const oursContent = ours.stdout;
471
+ const lfs_pointer =
472
+ ours.stdout.includes('version https://git-lfs.github.com/spec/v1') ||
473
+ theirs.stdout.includes('version https://git-lfs.github.com/spec/v1');
474
+
475
+ const binary = !lfs_pointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
476
+
477
+ return {
478
+ path,
479
+ ours: ours.stdout,
480
+ theirs: theirs.stdout,
481
+ base: base.status === 0 ? base.stdout : null,
482
+ binary,
483
+ lfs_pointer,
484
+ };
485
+ }
486
+
487
+ checkoutConflictSide(path: string, side: 'ours' | 'theirs'): void {
488
+ const ref = side === 'ours' ? ':2' : ':3';
489
+ this.runChecked(['checkout', ref, '--', path], 'git-checkout-conflict-failed');
490
+ }
491
+
492
+ writeMergeResult(path: string, content: string): void {
493
+ const args = ['update-index', '--add', '--cacheinfo', '100644', this.hashObject(content), path];
494
+ this.runChecked(args, 'git-write-merge-result-failed');
495
+ }
496
+
497
+ private hashObject(content: string): string {
498
+ return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
499
+ }
500
+
501
+ stagePatch(patch: string): void {
502
+ this.runChecked(['apply', '--3way', '--index'], 'git-stage-patch-failed', { stdin: patch });
503
+ }
504
+
505
+ applyReversePatch(patch: string): void {
506
+ this.runChecked(['apply', '-R', patch], 'git-apply-reverse-failed');
507
+ }
508
+
509
+ hardResetHead(ref?: string): void {
510
+ this.runChecked(['reset', '--hard', ref ?? 'HEAD'], 'git-reset-hard-failed');
511
+ }
512
+
513
+ resetSoftTo(ref: string): void {
514
+ this.runChecked(['reset', '--soft', ref], 'git-reset-soft-failed');
515
+ }
516
+
517
+ getIdentity(): { name: string; email: string } | null {
518
+ const nameResult = this.run(['config', '--local', 'user.name']);
519
+ const emailResult = this.run(['config', '--local', 'user.email']);
520
+
521
+ if (nameResult.status !== 0 || emailResult.status !== 0) {
522
+ return null;
523
+ }
524
+
525
+ return {
526
+ name: nameResult.stdout.trim(),
527
+ email: emailResult.stdout.trim(),
528
+ };
529
+ }
530
+
531
+ setIdentityLocal(name: string, email: string): void {
532
+ this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
533
+ this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
534
+ }
535
+
536
+ mergeIntoCurrent(branch: string): void {
537
+ this.runChecked(['merge', branch], 'git-merge-failed');
538
+ }
539
+
540
+ mergeAbort(): void {
541
+ this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
542
+ }
543
+
544
+ mergeContinue(message?: string): void {
545
+ const args = ['commit'];
546
+ if (message) {
547
+ args.push('-m', message);
548
+ }
549
+ this.runChecked(args, 'git-merge-continue-failed');
550
+ }
551
+
552
+ isMergeInProgress(): boolean {
553
+ const result = this.run(['rev-parse', '--verify', '-q', 'MERGE_HEAD']);
554
+ return result.status === 0;
555
+ }
556
+
557
+ listStashes(): StashEntry[] {
558
+ const result = this.runChecked(
559
+ ['stash', 'list', '--pretty=format:%gd%x1f%s%x1e'],
560
+ 'git-stash-list-failed',
561
+ );
562
+ return result.stdout
563
+ .split('\u001e')
564
+ .map((line) => line.trim())
565
+ .filter(Boolean)
566
+ .map((line) => {
567
+ const [selector = '', msg = ''] = line.split('\u001f');
568
+ return { selector, msg, meta: '' };
569
+ });
570
+ }
571
+
572
+ stashPush(message?: string, includeUntracked?: boolean): string {
573
+ const args = ['stash', 'push'];
574
+ if (includeUntracked) {
575
+ args.push('--include-untracked');
576
+ }
577
+ if (message) {
578
+ args.push('-m', message);
579
+ }
580
+ this.runChecked(args, 'git-stash-push-failed');
581
+ return this.runChecked(
582
+ ['stash', 'list', '-n', '1', '--pretty=format:%gd'],
583
+ 'git-stash-push-failed',
584
+ ).stdout.trim();
585
+ }
586
+
587
+ stashApply(selector: string): void {
588
+ this.runChecked(['stash', 'apply', selector], 'git-stash-apply-failed');
589
+ }
590
+
591
+ stashPop(selector: string): void {
592
+ this.runChecked(['stash', 'pop', selector], 'git-stash-pop-failed');
593
+ }
594
+
595
+ stashDrop(selector: string): void {
596
+ this.runChecked(['stash', 'drop', selector], 'git-stash-drop-failed');
597
+ }
598
+
599
+ stashShow(selector: string): string {
600
+ return this.runChecked(['stash', 'show', '-p', selector], 'git-stash-show-failed').stdout;
601
+ }
602
+
603
+ cherryPick(commit: string): void {
604
+ this.runChecked(['cherry-pick', commit], 'git-cherry-pick-failed');
605
+ }
606
+
607
+ revertCommit(commit: string, noEdit?: boolean): void {
608
+ const args = ['revert'];
609
+ if (noEdit) {
610
+ args.push('--no-edit');
611
+ }
612
+ args.push(commit);
613
+ this.runChecked(args, 'git-revert-failed');
614
+ }
615
+ }