@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/ARCHITECTURE.md +66 -0
- package/LICENSE +674 -0
- package/README.md +66 -0
- package/bin/git.js +446 -0
- package/bin/openvcs-git-plugin.js +10 -0
- package/bin/plugin-helpers.js +149 -0
- package/bin/plugin-request-handler.js +336 -0
- package/bin/plugin-runtime.js +38 -0
- package/bin/plugin-types.js +3 -0
- package/bin/plugin.js +53 -0
- package/bin/submodules.js +190 -0
- package/icon.png +0 -0
- package/package.json +48 -0
- package/src/git.ts +615 -0
- package/src/plugin-helpers.ts +184 -0
- package/src/plugin-request-handler.ts +593 -0
- package/src/plugin-runtime.ts +52 -0
- package/src/plugin-types.ts +35 -0
- package/src/plugin.ts +75 -0
- package/src/submodules.ts +219 -0
- package/tsconfig.json +16 -0
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
|
+
}
|