@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.
@@ -0,0 +1,184 @@
1
+ // Copyright © 2025-2026 OpenVCS Contributors
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import type {
5
+ CommitEntry,
6
+ RequestParams,
7
+ StatusFileEntry,
8
+ StatusParseResult,
9
+ StatusSummary,
10
+ } from './plugin-types.js';
11
+
12
+ /** Returns a plain object parameter map or an empty object for invalid input. */
13
+ export function asRecord(value: unknown): RequestParams {
14
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
15
+ return {};
16
+ }
17
+
18
+ return value as RequestParams;
19
+ }
20
+
21
+ /** Coerces any value into a string while preserving empty defaults. */
22
+ export function asString(value: unknown): string {
23
+ return typeof value === 'string' ? value : String(value ?? '');
24
+ }
25
+
26
+ /** Coerces any value into a trimmed string. */
27
+ export function asTrimmedString(value: unknown): string {
28
+ return asString(value).trim();
29
+ }
30
+
31
+ /** Coerces any value into a finite number or returns a fallback. */
32
+ export function asNumber(value: unknown, fallback: number): number {
33
+ const numericValue = typeof value === 'number' ? value : Number(value);
34
+ return Number.isFinite(numericValue) ? numericValue : fallback;
35
+ }
36
+
37
+ /** Coerces an unknown value into a filtered list of non-empty strings. */
38
+ export function asStringArray(value: unknown): string[] {
39
+ if (!Array.isArray(value)) {
40
+ return [];
41
+ }
42
+
43
+ return value.map((entry) => asString(entry)).filter(Boolean);
44
+ }
45
+
46
+ /** Adds a trimmed string argument when a value is present. */
47
+ function pushOptionalArg(args: string[], value: unknown): void {
48
+ const candidate = asTrimmedString(value);
49
+ if (candidate) {
50
+ args.push(candidate);
51
+ }
52
+ }
53
+
54
+ /** Builds `git fetch` arguments while omitting empty optional values. */
55
+ export function buildFetchArgs(params: RequestParams): string[] {
56
+ const args = ['fetch'];
57
+ const options = asRecord(params.opts);
58
+
59
+ if (options.prune === true) {
60
+ args.push('--prune');
61
+ }
62
+
63
+ pushOptionalArg(args, params.remote);
64
+ pushOptionalArg(args, params.refspec);
65
+
66
+ return args;
67
+ }
68
+
69
+ /** Builds `git push` arguments while omitting empty optional values. */
70
+ export function buildPushArgs(params: RequestParams): string[] {
71
+ const args = ['push'];
72
+ pushOptionalArg(args, params.remote);
73
+ pushOptionalArg(args, params.refspec);
74
+ return args;
75
+ }
76
+
77
+ /** Builds `git pull --ff-only` arguments while omitting empty optional values. */
78
+ export function buildPullFfOnlyArgs(params: RequestParams): string[] {
79
+ const args = ['pull', '--ff-only'];
80
+ pushOptionalArg(args, params.remote);
81
+ pushOptionalArg(args, params.branch);
82
+ return args;
83
+ }
84
+
85
+ /** Parses `git status --porcelain=1 --branch -z -uall` output into OpenVCS payloads. */
86
+ export function parseStatusOutput(output: string): StatusParseResult {
87
+ const records = output.split('\0').filter(Boolean);
88
+ let ahead = 0;
89
+ let behind = 0;
90
+ const files: StatusFileEntry[] = [];
91
+ const summary: StatusSummary = {
92
+ untracked: 0,
93
+ modified: 0,
94
+ staged: 0,
95
+ conflicted: 0,
96
+ };
97
+
98
+ for (let index = 0; index < records.length; index += 1) {
99
+ const record = records[index];
100
+
101
+ if (record.startsWith('## ')) {
102
+ const aheadMatch = record.match(/ahead\s+(\d+)/);
103
+ const behindMatch = record.match(/behind\s+(\d+)/);
104
+ ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
105
+ behind = behindMatch ? Number(behindMatch[1]) : 0;
106
+ continue;
107
+ }
108
+
109
+ if (record.length < 4) {
110
+ continue;
111
+ }
112
+
113
+ const x = record[0];
114
+ const y = record[1];
115
+ const payloadPath = record.slice(3);
116
+ const renamedOrCopied = x === 'R' || x === 'C' || y === 'R' || y === 'C';
117
+ let path = payloadPath;
118
+ let oldPath: string | null = null;
119
+
120
+ if (renamedOrCopied && index + 1 < records.length) {
121
+ path = records[index + 1];
122
+ oldPath = payloadPath;
123
+ index += 1;
124
+ }
125
+
126
+ const staged = x !== ' ' && x !== '?';
127
+
128
+ if (x === '?' || y === '?') {
129
+ summary.untracked += 1;
130
+ } else if (
131
+ x === 'U' ||
132
+ y === 'U' ||
133
+ (x === 'A' && y === 'A') ||
134
+ (x === 'D' && y === 'D')
135
+ ) {
136
+ summary.conflicted += 1;
137
+ } else {
138
+ if (staged) {
139
+ summary.staged += 1;
140
+ }
141
+
142
+ if (y !== ' ') {
143
+ summary.modified += 1;
144
+ }
145
+ }
146
+
147
+ files.push({
148
+ path,
149
+ old_path: oldPath,
150
+ status: `${x}${y}`.trim() || 'M',
151
+ staged,
152
+ resolved_conflict: false,
153
+ hunks: [],
154
+ });
155
+ }
156
+
157
+ return {
158
+ summary,
159
+ payload: {
160
+ files,
161
+ ahead,
162
+ behind,
163
+ },
164
+ };
165
+ }
166
+
167
+ /** Parses `git log` output into commit entries expected by the host. */
168
+ export function parseCommits(raw: string): CommitEntry[] {
169
+ const records = raw
170
+ .split('\u001e')
171
+ .map((record) => record.trim())
172
+ .filter(Boolean);
173
+
174
+ return records.map((record) => {
175
+ const [id, msg, author, meta, parent_oid = ''] = record.split('\u0000');
176
+ return {
177
+ id: asString(id),
178
+ msg: asString(msg),
179
+ author: asString(author),
180
+ meta: asString(meta),
181
+ parent_oid: parent_oid || undefined,
182
+ };
183
+ });
184
+ }