@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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Git Plugin
2
+
3
+ This directory contains the System Git VCS backend plugin used by OpenVCS.
4
+
5
+ ## Runtime model
6
+
7
+ - The plugin runs as a long-lived Node.js process.
8
+ - The plugin implements the JSON-RPC contract used by the backend runtime (`plugin.*` and `vcs.*`) through the shared SDK runtime delegates.
9
+ - The plugin can add top-level app menus and items through `@openvcs/sdk/runtime` helpers.
10
+ - The plugin can open generic plugin-owned modals with the SDK `ModalBuilder` helper.
11
+ - The Repository menu includes Git-only submodule management tooling.
12
+ - Git operations are executed through the local `git` CLI.
13
+ - The runtime uses a trust model (no per-capability permission prompts).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install
19
+ ```
20
+
21
+ - The SDK dependency is pinned to the `^0.2` range so it tracks the latest `0.2.x` releases.
22
+
23
+ ## Validate
24
+
25
+ ```bash
26
+ npm run lint
27
+ ```
28
+
29
+ ## Build
30
+
31
+ ```bash
32
+ npm run build
33
+ ```
34
+
35
+ - TypeScript sources live in `src/`.
36
+ - `npm run build` runs `openvcs build`, which invokes `build:plugin` and writes the runtime into `bin/`.
37
+
38
+ ## Test
39
+
40
+ ```bash
41
+ npm test
42
+ ```
43
+
44
+ ## Pack For Config Use
45
+
46
+ ```bash
47
+ npm pack
48
+ ```
49
+
50
+ - `npm pack` uses the package `files` list and `prepack` hook.
51
+ - OpenVCS resolves published packages and local path plugins through npm package semantics.
52
+
53
+ ## Release Channels
54
+
55
+ The npm package can be consumed from prerelease channels published by CI:
56
+
57
+ - `latest`: stable releases
58
+ - `beta`: builds from the `Beta` branch
59
+ - `nightly`: scheduled builds from `Dev` when there are changes since the last nightly
60
+
61
+ Examples:
62
+
63
+ ```bash
64
+ npm install @openvcs/git-plugin@beta
65
+ npm install @openvcs/git-plugin@nightly
66
+ ```
package/bin/git.js ADDED
@@ -0,0 +1,446 @@
1
+ // Copyright © 2025-2026 OpenVCS Contributors
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ import { spawnSync } from 'node:child_process';
4
+ import { rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { pluginError } from '@openvcs/sdk/runtime';
7
+ import { asString, buildFetchArgs, buildPullFfOnlyArgs, buildPushArgs, parseCommits, parseStatusOutput, } from './plugin-helpers.js';
8
+ export class GitCommand {
9
+ cwd;
10
+ constructor(cwd) {
11
+ this.cwd = cwd;
12
+ }
13
+ run(args, options = {}) {
14
+ const result = spawnSync('git', args, {
15
+ cwd: this.cwd,
16
+ input: typeof options.stdin === 'string' ? options.stdin : undefined,
17
+ encoding: 'utf8',
18
+ maxBuffer: 16 * 1024 * 1024,
19
+ });
20
+ if (result.status === null) {
21
+ const signal = result.signal ?? 'unknown';
22
+ console.warn(`git process killed/crashed (signal: ${signal}) in ${this.cwd}: ${args.join(' ')}`);
23
+ return {
24
+ status: -2,
25
+ stdout: asString(result.stdout),
26
+ stderr: asString(result.stderr) || `Process terminated by signal: ${signal}`,
27
+ };
28
+ }
29
+ return {
30
+ status: result.status,
31
+ stdout: asString(result.stdout),
32
+ stderr: asString(result.stderr),
33
+ };
34
+ }
35
+ runChecked(args, errorCode, options = {}) {
36
+ const output = this.run(args, options);
37
+ if (output.status !== 0) {
38
+ const exitInfo = output.status === -2 ? ` (signal: ${output.stderr})` : ` (exit code: ${output.status})`;
39
+ const message = output.stderr.trim() ||
40
+ output.stdout.trim() ||
41
+ `git ${args.join(' ')}${exitInfo}`;
42
+ throw pluginError(errorCode, message);
43
+ }
44
+ return output;
45
+ }
46
+ version() {
47
+ const result = this.run(['--version']);
48
+ const versionMatch = result.stdout.match(/git version (\d+)\.(\d+)/);
49
+ if (!versionMatch) {
50
+ throw new Error(`Unable to parse Git version: ${result.stdout.trim()}`);
51
+ }
52
+ const major = parseInt(versionMatch[1], 10);
53
+ const minor = parseInt(versionMatch[2], 10);
54
+ return {
55
+ result,
56
+ version: versionMatch[0],
57
+ major,
58
+ minor,
59
+ };
60
+ }
61
+ status() {
62
+ const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
63
+ const parsed = parseStatusOutput(result.stdout);
64
+ return { ...parsed, exitCode: result.status };
65
+ }
66
+ currentBranch() {
67
+ const result = this.runChecked(['rev-parse', '--abbrev-ref', 'HEAD'], 'git-branch-failed');
68
+ return result.stdout.trim();
69
+ }
70
+ listBranches() {
71
+ const result = this.run(['branch', '-a', '--format=%(refname:short)%(if)%(HEAD)%(then)*%(end)']);
72
+ const current = this.currentBranch();
73
+ const branches = [];
74
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
75
+ const isCurrent = line.endsWith('*');
76
+ const baseLine = isCurrent ? line.slice(0, -1) : line;
77
+ const name = baseLine.trim();
78
+ if (name) {
79
+ branches.push({ name, current: name === current || (isCurrent && name === current) });
80
+ }
81
+ }
82
+ return { current, branches };
83
+ }
84
+ listLocalBranches() {
85
+ const result = this.run(['branch', '--format=%(refname:short)%(if)%(HEAD)%(then)*%(end)']);
86
+ const current = this.currentBranch();
87
+ const branches = [];
88
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
89
+ const trimmed = line.replaceAll('*', '').trim();
90
+ if (trimmed) {
91
+ branches.push({ name: trimmed, current: trimmed === current });
92
+ }
93
+ }
94
+ return { current, branches };
95
+ }
96
+ createBranch(name) {
97
+ this.runChecked(['branch', name], 'git-branch-create-failed');
98
+ }
99
+ checkoutBranch(name) {
100
+ this.runChecked(['checkout', name], 'git-checkout-failed');
101
+ }
102
+ deleteBranch(name) {
103
+ this.runChecked(['branch', '-d', name], 'git-branch-delete-failed');
104
+ }
105
+ renameBranch(oldName, newName) {
106
+ this.runChecked(['branch', '-m', oldName, newName], 'git-branch-rename-failed');
107
+ }
108
+ getBranchUpstream(branchName) {
109
+ const result = this.run(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]);
110
+ return result.status === 0 ? result.stdout.trim() : null;
111
+ }
112
+ setBranchUpstream(branchName, upstream) {
113
+ this.runChecked(['branch', '--set-upstream-to', upstream, branchName], 'git-branch-upstream-failed');
114
+ }
115
+ ensureRemote(name, url) {
116
+ const probe = this.run(['remote', 'get-url', name]);
117
+ if (probe.status === 0) {
118
+ if (probe.stdout.trim() !== url) {
119
+ this.runChecked(['remote', 'set-url', name, url], 'git-remote-set-url-failed');
120
+ }
121
+ }
122
+ else {
123
+ this.runChecked(['remote', 'add', name, url], 'git-remote-add-failed');
124
+ }
125
+ }
126
+ listRemotes() {
127
+ const result = this.runChecked(['remote', '-v'], 'git-remote-list-failed');
128
+ const remotes = [];
129
+ const remoteMap = new Map();
130
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
131
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
132
+ if (match) {
133
+ const [, name, url, type] = match;
134
+ if (!remoteMap.has(name)) {
135
+ remoteMap.set(name, { fetch: '', push: '' });
136
+ }
137
+ const entry = remoteMap.get(name);
138
+ if (type === 'fetch') {
139
+ entry.fetch = url;
140
+ }
141
+ else if (type === 'push') {
142
+ entry.push = url;
143
+ }
144
+ }
145
+ }
146
+ remoteMap.forEach((value, key) => {
147
+ remotes.push({ name: key, ...value });
148
+ });
149
+ return { remotes };
150
+ }
151
+ removeRemote(name) {
152
+ this.runChecked(['remote', 'remove', name], 'git-remote-remove-failed');
153
+ }
154
+ fetch(options = {}) {
155
+ const args = buildFetchArgs(options);
156
+ return this.runChecked(args, 'git-fetch-failed');
157
+ }
158
+ push(options = {}) {
159
+ const args = buildPushArgs(options);
160
+ return this.runChecked(args, 'git-push-failed');
161
+ }
162
+ pull(options = {}) {
163
+ const args = buildPullFfOnlyArgs(options);
164
+ return this.runChecked(args, 'git-pull-failed');
165
+ }
166
+ commit(message) {
167
+ return this.runChecked(['commit', '-m', message], 'git-commit-failed');
168
+ }
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
+ }
177
+ const commitMessage = message || 'Stage changes';
178
+ const execArgs = [
179
+ ...(name ? ['-c', `user.name=${name}`] : []),
180
+ ...(email ? ['-c', `user.email=${email}`] : []),
181
+ ...args,
182
+ '-m', commitMessage,
183
+ ];
184
+ return this.runChecked(execArgs, 'git-commit-failed');
185
+ }
186
+ listCommits(options = {}) {
187
+ const args = ['log', '--all'];
188
+ if (options.topo_order) {
189
+ args.push('--topo-order');
190
+ }
191
+ if (options.limit !== undefined) {
192
+ args.push(`-${options.limit}`);
193
+ }
194
+ if (options.skip !== undefined) {
195
+ args.push(`--skip=${options.skip}`);
196
+ }
197
+ if (options.include_merges === false) {
198
+ args.push('--no-merges');
199
+ }
200
+ if (options.author_contains) {
201
+ args.push(`--author=${options.author_contains}`);
202
+ }
203
+ if (options.since_utc) {
204
+ args.push(`--since=${options.since_utc}`);
205
+ }
206
+ if (options.until_utc) {
207
+ args.push(`--until=${options.until_utc}`);
208
+ }
209
+ args.push('--pretty=format:%H%f%x00%aN%x00%aI%x00%P%x1e');
210
+ if (options.branch) {
211
+ args.push(options.branch);
212
+ }
213
+ if (options.path) {
214
+ args.push('--', options.path);
215
+ }
216
+ const result = this.run(args);
217
+ const commits = parseCommits(result.stdout);
218
+ return { commits, exitCode: result.status };
219
+ }
220
+ listSubmodules() {
221
+ const configResult = this.run(['config', '-f', '.gitmodules', '--null', '--list']);
222
+ const configByName = new Map();
223
+ if (configResult.status === 0) {
224
+ for (const entry of configResult.stdout.split('\0')) {
225
+ const trimmed = entry.trim();
226
+ if (!trimmed)
227
+ continue;
228
+ const splitAt = trimmed.indexOf('\n');
229
+ if (splitAt < 0)
230
+ continue;
231
+ const key = trimmed.slice(0, splitAt).trim();
232
+ const value = trimmed.slice(splitAt + 1);
233
+ const match = key.match(/^submodule\.(.+)\.(path|url|branch)$/);
234
+ if (!match)
235
+ continue;
236
+ const [, name, field] = match;
237
+ const target = configByName.get(name) || { name };
238
+ if (field === 'path')
239
+ target.path = value;
240
+ if (field === 'url')
241
+ target.url = value;
242
+ if (field === 'branch')
243
+ target.branch = value;
244
+ configByName.set(name, target);
245
+ }
246
+ }
247
+ const configByPath = new Map();
248
+ for (const entry of configByName.values()) {
249
+ if (entry.path) {
250
+ configByPath.set(entry.path, entry);
251
+ }
252
+ }
253
+ const statusResult = this.run(['submodule', 'status', '--recursive']);
254
+ if (statusResult.status !== 0) {
255
+ return [];
256
+ }
257
+ const stateFor = (marker) => {
258
+ if (marker === '-')
259
+ return 'uninitialized';
260
+ if (marker === '+')
261
+ return 'dirty';
262
+ if (marker === 'U')
263
+ return 'conflicted';
264
+ return 'clean';
265
+ };
266
+ const entries = [];
267
+ for (const rawLine of statusResult.stdout.split(/\r?\n/g)) {
268
+ const line = rawLine.trim();
269
+ if (!line)
270
+ continue;
271
+ const marker = line[0] || ' ';
272
+ const rest = line.slice(1).trim();
273
+ const [commit = '', path = ''] = rest.split(/\s+/);
274
+ if (!path)
275
+ continue;
276
+ const config = configByPath.get(path);
277
+ entries.push({
278
+ path,
279
+ name: config?.name || path.split('/').filter(Boolean).at(-1) || path,
280
+ url: config?.url,
281
+ branch: config?.branch,
282
+ commit,
283
+ state: stateFor(marker),
284
+ });
285
+ }
286
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
287
+ }
288
+ addSubmodule(url, path, name, branch) {
289
+ const args = ['submodule', 'add'];
290
+ if (name)
291
+ args.push('--name', name);
292
+ if (branch)
293
+ args.push('--branch', branch);
294
+ args.push(url, path);
295
+ return this.runChecked(args, 'git-submodule-add-failed');
296
+ }
297
+ updateSubmodule(path) {
298
+ return this.runChecked(['submodule', 'update', '--init', '--recursive', '--', path], 'git-submodule-update-failed');
299
+ }
300
+ updateAllSubmodules() {
301
+ return this.runChecked(['submodule', 'update', '--init', '--recursive'], 'git-submodule-update-failed');
302
+ }
303
+ syncSubmodule(path) {
304
+ return this.runChecked(['submodule', 'sync', '--recursive', '--', path], 'git-submodule-sync-failed');
305
+ }
306
+ syncAllSubmodules() {
307
+ return this.runChecked(['submodule', 'sync', '--recursive'], 'git-submodule-sync-failed');
308
+ }
309
+ removeSubmodule(path) {
310
+ this.runChecked(['submodule', 'deinit', '-f', '--', path], 'git-submodule-remove-failed');
311
+ this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
312
+ const modulesPath = join(this.cwd, '.git', 'modules', path);
313
+ try {
314
+ rmSync(modulesPath, { recursive: true, force: true });
315
+ }
316
+ catch {
317
+ // Best-effort cleanup.
318
+ }
319
+ }
320
+ diffFile(path) {
321
+ return this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed').stdout;
322
+ }
323
+ diffCommit(commit) {
324
+ return this.runChecked(['diff', `${commit}^`, commit], 'git-diff-failed').stdout;
325
+ }
326
+ getConflictDetails(path) {
327
+ const ours = this.run(['show', `:2:${path}`]);
328
+ const theirs = this.run(['show', `:3:${path}`]);
329
+ const base = this.run(['show', `:1:${path}`]);
330
+ if (ours.status !== 0 || theirs.status !== 0) {
331
+ return { path, ours: null, theirs: null, base: null, binary: false, lfs_pointer: false };
332
+ }
333
+ const oursContent = ours.stdout;
334
+ const lfs_pointer = ours.stdout.includes('version https://git-lfs.github.com/spec/v1') ||
335
+ theirs.stdout.includes('version https://git-lfs.github.com/spec/v1');
336
+ const binary = !lfs_pointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
337
+ return {
338
+ path,
339
+ ours: ours.stdout,
340
+ theirs: theirs.stdout,
341
+ base: base.status === 0 ? base.stdout : null,
342
+ binary,
343
+ lfs_pointer,
344
+ };
345
+ }
346
+ checkoutConflictSide(path, side) {
347
+ const ref = side === 'ours' ? ':2' : ':3';
348
+ this.runChecked(['checkout', ref, '--', path], 'git-checkout-conflict-failed');
349
+ }
350
+ writeMergeResult(path, content) {
351
+ const args = ['update-index', '--add', '--cacheinfo', '100644', this.hashObject(content), path];
352
+ this.runChecked(args, 'git-write-merge-result-failed');
353
+ }
354
+ hashObject(content) {
355
+ return this.run(['hash-object', '-w', '--stdin'], { stdin: content }).stdout.trim();
356
+ }
357
+ stagePatch(patch) {
358
+ this.runChecked(['apply', '--3way', '--index'], 'git-stage-patch-failed', { stdin: patch });
359
+ }
360
+ applyReversePatch(patch) {
361
+ this.runChecked(['apply', '-R', patch], 'git-apply-reverse-failed');
362
+ }
363
+ hardResetHead(ref) {
364
+ this.runChecked(['reset', '--hard', ref ?? 'HEAD'], 'git-reset-hard-failed');
365
+ }
366
+ resetSoftTo(ref) {
367
+ this.runChecked(['reset', '--soft', ref], 'git-reset-soft-failed');
368
+ }
369
+ getIdentity() {
370
+ const nameResult = this.run(['config', '--local', 'user.name']);
371
+ const emailResult = this.run(['config', '--local', 'user.email']);
372
+ if (nameResult.status !== 0 || emailResult.status !== 0) {
373
+ return null;
374
+ }
375
+ return {
376
+ name: nameResult.stdout.trim(),
377
+ email: emailResult.stdout.trim(),
378
+ };
379
+ }
380
+ setIdentityLocal(name, email) {
381
+ this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
382
+ this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
383
+ }
384
+ mergeIntoCurrent(branch) {
385
+ this.runChecked(['merge', branch], 'git-merge-failed');
386
+ }
387
+ mergeAbort() {
388
+ this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
389
+ }
390
+ mergeContinue(message) {
391
+ const args = ['commit'];
392
+ if (message) {
393
+ args.push('-m', message);
394
+ }
395
+ this.runChecked(args, 'git-merge-continue-failed');
396
+ }
397
+ isMergeInProgress() {
398
+ const result = this.run(['rev-parse', '--verify', '-q', 'MERGE_HEAD']);
399
+ return result.status === 0;
400
+ }
401
+ listStashes() {
402
+ const result = this.runChecked(['stash', 'list', '--pretty=format:%gd%x1f%s%x1e'], 'git-stash-list-failed');
403
+ return result.stdout
404
+ .split('\u001e')
405
+ .map((line) => line.trim())
406
+ .filter(Boolean)
407
+ .map((line) => {
408
+ const [selector = '', msg = ''] = line.split('\u001f');
409
+ return { selector, msg, meta: '' };
410
+ });
411
+ }
412
+ stashPush(message, includeUntracked) {
413
+ const args = ['stash', 'push'];
414
+ if (includeUntracked) {
415
+ args.push('--include-untracked');
416
+ }
417
+ if (message) {
418
+ args.push('-m', message);
419
+ }
420
+ this.runChecked(args, 'git-stash-push-failed');
421
+ return this.runChecked(['stash', 'list', '-n', '1', '--pretty=format:%gd'], 'git-stash-push-failed').stdout.trim();
422
+ }
423
+ stashApply(selector) {
424
+ this.runChecked(['stash', 'apply', selector], 'git-stash-apply-failed');
425
+ }
426
+ stashPop(selector) {
427
+ this.runChecked(['stash', 'pop', selector], 'git-stash-pop-failed');
428
+ }
429
+ stashDrop(selector) {
430
+ this.runChecked(['stash', 'drop', selector], 'git-stash-drop-failed');
431
+ }
432
+ stashShow(selector) {
433
+ return this.runChecked(['stash', 'show', '-p', selector], 'git-stash-show-failed').stdout;
434
+ }
435
+ cherryPick(commit) {
436
+ this.runChecked(['cherry-pick', commit], 'git-cherry-pick-failed');
437
+ }
438
+ revertCommit(commit, noEdit) {
439
+ const args = ['revert'];
440
+ if (noEdit) {
441
+ args.push('--no-edit');
442
+ }
443
+ args.push(commit);
444
+ this.runChecked(args, 'git-revert-failed');
445
+ }
446
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { bootstrapPluginModule } from '@openvcs/sdk/runtime';
6
+
7
+ await bootstrapPluginModule({
8
+ importPluginModule: async () => import('./plugin.js'),
9
+ modulePath: './plugin.js',
10
+ });
@@ -0,0 +1,149 @@
1
+ // Copyright © 2025-2026 OpenVCS Contributors
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ /** Returns a plain object parameter map or an empty object for invalid input. */
4
+ export function asRecord(value) {
5
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
6
+ return {};
7
+ }
8
+ return value;
9
+ }
10
+ /** Coerces any value into a string while preserving empty defaults. */
11
+ export function asString(value) {
12
+ return typeof value === 'string' ? value : String(value ?? '');
13
+ }
14
+ /** Coerces any value into a trimmed string. */
15
+ export function asTrimmedString(value) {
16
+ return asString(value).trim();
17
+ }
18
+ /** Coerces any value into a finite number or returns a fallback. */
19
+ export function asNumber(value, fallback) {
20
+ const numericValue = typeof value === 'number' ? value : Number(value);
21
+ return Number.isFinite(numericValue) ? numericValue : fallback;
22
+ }
23
+ /** Coerces an unknown value into a filtered list of non-empty strings. */
24
+ export function asStringArray(value) {
25
+ if (!Array.isArray(value)) {
26
+ return [];
27
+ }
28
+ return value.map((entry) => asString(entry)).filter(Boolean);
29
+ }
30
+ /** Adds a trimmed string argument when a value is present. */
31
+ function pushOptionalArg(args, value) {
32
+ const candidate = asTrimmedString(value);
33
+ if (candidate) {
34
+ args.push(candidate);
35
+ }
36
+ }
37
+ /** Builds `git fetch` arguments while omitting empty optional values. */
38
+ export function buildFetchArgs(params) {
39
+ const args = ['fetch'];
40
+ const options = asRecord(params.opts);
41
+ if (options.prune === true) {
42
+ args.push('--prune');
43
+ }
44
+ pushOptionalArg(args, params.remote);
45
+ pushOptionalArg(args, params.refspec);
46
+ return args;
47
+ }
48
+ /** Builds `git push` arguments while omitting empty optional values. */
49
+ export function buildPushArgs(params) {
50
+ const args = ['push'];
51
+ pushOptionalArg(args, params.remote);
52
+ pushOptionalArg(args, params.refspec);
53
+ return args;
54
+ }
55
+ /** Builds `git pull --ff-only` arguments while omitting empty optional values. */
56
+ export function buildPullFfOnlyArgs(params) {
57
+ const args = ['pull', '--ff-only'];
58
+ pushOptionalArg(args, params.remote);
59
+ pushOptionalArg(args, params.branch);
60
+ return args;
61
+ }
62
+ /** Parses `git status --porcelain=1 --branch -z -uall` output into OpenVCS payloads. */
63
+ export function parseStatusOutput(output) {
64
+ const records = output.split('\0').filter(Boolean);
65
+ let ahead = 0;
66
+ let behind = 0;
67
+ const files = [];
68
+ const summary = {
69
+ untracked: 0,
70
+ modified: 0,
71
+ staged: 0,
72
+ conflicted: 0,
73
+ };
74
+ for (let index = 0; index < records.length; index += 1) {
75
+ const record = records[index];
76
+ if (record.startsWith('## ')) {
77
+ const aheadMatch = record.match(/ahead\s+(\d+)/);
78
+ const behindMatch = record.match(/behind\s+(\d+)/);
79
+ ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
80
+ behind = behindMatch ? Number(behindMatch[1]) : 0;
81
+ continue;
82
+ }
83
+ if (record.length < 4) {
84
+ continue;
85
+ }
86
+ const x = record[0];
87
+ const y = record[1];
88
+ const payloadPath = record.slice(3);
89
+ const renamedOrCopied = x === 'R' || x === 'C' || y === 'R' || y === 'C';
90
+ let path = payloadPath;
91
+ let oldPath = null;
92
+ if (renamedOrCopied && index + 1 < records.length) {
93
+ path = records[index + 1];
94
+ oldPath = payloadPath;
95
+ index += 1;
96
+ }
97
+ const staged = x !== ' ' && x !== '?';
98
+ if (x === '?' || y === '?') {
99
+ summary.untracked += 1;
100
+ }
101
+ else if (x === 'U' ||
102
+ y === 'U' ||
103
+ (x === 'A' && y === 'A') ||
104
+ (x === 'D' && y === 'D')) {
105
+ summary.conflicted += 1;
106
+ }
107
+ else {
108
+ if (staged) {
109
+ summary.staged += 1;
110
+ }
111
+ if (y !== ' ') {
112
+ summary.modified += 1;
113
+ }
114
+ }
115
+ files.push({
116
+ path,
117
+ old_path: oldPath,
118
+ status: `${x}${y}`.trim() || 'M',
119
+ staged,
120
+ resolved_conflict: false,
121
+ hunks: [],
122
+ });
123
+ }
124
+ return {
125
+ summary,
126
+ payload: {
127
+ files,
128
+ ahead,
129
+ behind,
130
+ },
131
+ };
132
+ }
133
+ /** Parses `git log` output into commit entries expected by the host. */
134
+ export function parseCommits(raw) {
135
+ const records = raw
136
+ .split('\u001e')
137
+ .map((record) => record.trim())
138
+ .filter(Boolean);
139
+ return records.map((record) => {
140
+ const [id, msg, author, meta, parent_oid = ''] = record.split('\u0000');
141
+ return {
142
+ id: asString(id),
143
+ msg: asString(msg),
144
+ author: asString(author),
145
+ meta: asString(meta),
146
+ parent_oid: parent_oid || undefined,
147
+ };
148
+ });
149
+ }