svn-agent-mcp 1.0.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.
Files changed (165) hide show
  1. package/.editorconfig +12 -0
  2. package/.gitattributes +7 -0
  3. package/.github/workflows/ci.yml +39 -0
  4. package/.svn-mcp-policy.json +9 -0
  5. package/CHANGELOG.md +248 -0
  6. package/CONTRIBUTING.md +49 -0
  7. package/LICENSE +201 -0
  8. package/README.md +143 -0
  9. package/SECURITY.md +21 -0
  10. package/THIRD_PARTY_CHECKSUMS.txt +52 -0
  11. package/THIRD_PARTY_NOTICES.md +46 -0
  12. package/bin/SlikSvn-DB44-20-x64.dll +0 -0
  13. package/bin/SlikSvn-libapr-1.dll +0 -0
  14. package/bin/SlikSvn-libaprutil-1.dll +0 -0
  15. package/bin/SlikSvn-libcrypto-3-x64.dll +0 -0
  16. package/bin/SlikSvn-libintl.dll +0 -0
  17. package/bin/SlikSvn-libsasl21.dll +0 -0
  18. package/bin/SlikSvn-libssl-3-x64.dll +0 -0
  19. package/bin/SlikSvn-libsvn_client-1.dll +0 -0
  20. package/bin/SlikSvn-libsvn_delta-1.dll +0 -0
  21. package/bin/SlikSvn-libsvn_diff-1.dll +0 -0
  22. package/bin/SlikSvn-libsvn_fs-1.dll +0 -0
  23. package/bin/SlikSvn-libsvn_fs_base-1.dll +0 -0
  24. package/bin/SlikSvn-libsvn_fs_fs-1.dll +0 -0
  25. package/bin/SlikSvn-libsvn_fs_util-1.dll +0 -0
  26. package/bin/SlikSvn-libsvn_fs_x-1.dll +0 -0
  27. package/bin/SlikSvn-libsvn_ra-1.dll +0 -0
  28. package/bin/SlikSvn-libsvn_repos-1.dll +0 -0
  29. package/bin/SlikSvn-libsvn_subr-1.dll +0 -0
  30. package/bin/SlikSvn-libsvn_wc-1.dll +0 -0
  31. package/bin/System64/concrt140.dll +0 -0
  32. package/bin/System64/msvcp140.dll +0 -0
  33. package/bin/System64/msvcp140_1.dll +0 -0
  34. package/bin/System64/msvcp140_2.dll +0 -0
  35. package/bin/System64/msvcp140_atomic_wait.dll +0 -0
  36. package/bin/System64/msvcp140_codecvt_ids.dll +0 -0
  37. package/bin/System64/vccorlib140.dll +0 -0
  38. package/bin/System64/vcruntime140.dll +0 -0
  39. package/bin/System64/vcruntime140_1.dll +0 -0
  40. package/bin/dos2unix.exe +0 -0
  41. package/bin/engines/capi.dll +0 -0
  42. package/bin/libsvnjavahl-1.dll +0 -0
  43. package/bin/mac2unix.exe +0 -0
  44. package/bin/svn-populate-node-origins-index.exe +0 -0
  45. package/bin/svn.exe +0 -0
  46. package/bin/svnadmin.exe +0 -0
  47. package/bin/svnauthz-validate.exe +0 -0
  48. package/bin/svnauthz.exe +0 -0
  49. package/bin/svnbench.exe +0 -0
  50. package/bin/svndumpfilter.exe +0 -0
  51. package/bin/svnfsfs.exe +0 -0
  52. package/bin/svnlook.exe +0 -0
  53. package/bin/svnmucc.exe +0 -0
  54. package/bin/svnrdump.exe +0 -0
  55. package/bin/svnserve.exe +0 -0
  56. package/bin/svnsync.exe +0 -0
  57. package/bin/svnversion.exe +0 -0
  58. package/bin/unix2dos.exe +0 -0
  59. package/bin/unix2mac.exe +0 -0
  60. package/dist/envelope.d.ts +39 -0
  61. package/dist/envelope.d.ts.map +1 -0
  62. package/dist/envelope.js +137 -0
  63. package/dist/envelope.js.map +1 -0
  64. package/dist/eol.d.ts +15 -0
  65. package/dist/eol.d.ts.map +1 -0
  66. package/dist/eol.js +119 -0
  67. package/dist/eol.js.map +1 -0
  68. package/dist/guards.d.ts +26 -0
  69. package/dist/guards.d.ts.map +1 -0
  70. package/dist/guards.js +306 -0
  71. package/dist/guards.js.map +1 -0
  72. package/dist/index.d.ts +8 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +221 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/parse/commitText.d.ts +2 -0
  77. package/dist/parse/commitText.d.ts.map +1 -0
  78. package/dist/parse/commitText.js +9 -0
  79. package/dist/parse/commitText.js.map +1 -0
  80. package/dist/parse/diffText.d.ts +7 -0
  81. package/dist/parse/diffText.d.ts.map +1 -0
  82. package/dist/parse/diffText.js +55 -0
  83. package/dist/parse/diffText.js.map +1 -0
  84. package/dist/parse/infoXml.d.ts +3 -0
  85. package/dist/parse/infoXml.d.ts.map +1 -0
  86. package/dist/parse/infoXml.js +35 -0
  87. package/dist/parse/infoXml.js.map +1 -0
  88. package/dist/parse/logXml.d.ts +10 -0
  89. package/dist/parse/logXml.d.ts.map +1 -0
  90. package/dist/parse/logXml.js +39 -0
  91. package/dist/parse/logXml.js.map +1 -0
  92. package/dist/parse/statusXml.d.ts +6 -0
  93. package/dist/parse/statusXml.d.ts.map +1 -0
  94. package/dist/parse/statusXml.js +64 -0
  95. package/dist/parse/statusXml.js.map +1 -0
  96. package/dist/parse/updateText.d.ts +6 -0
  97. package/dist/parse/updateText.d.ts.map +1 -0
  98. package/dist/parse/updateText.js +67 -0
  99. package/dist/parse/updateText.js.map +1 -0
  100. package/dist/runner.d.ts +41 -0
  101. package/dist/runner.d.ts.map +1 -0
  102. package/dist/runner.js +270 -0
  103. package/dist/runner.js.map +1 -0
  104. package/dist/tools/composite.d.ts +15 -0
  105. package/dist/tools/composite.d.ts.map +1 -0
  106. package/dist/tools/composite.js +238 -0
  107. package/dist/tools/composite.js.map +1 -0
  108. package/dist/tools/diagnose.d.ts +6 -0
  109. package/dist/tools/diagnose.d.ts.map +1 -0
  110. package/dist/tools/diagnose.js +131 -0
  111. package/dist/tools/diagnose.js.map +1 -0
  112. package/dist/tools/mutating.d.ts +80 -0
  113. package/dist/tools/mutating.d.ts.map +1 -0
  114. package/dist/tools/mutating.js +558 -0
  115. package/dist/tools/mutating.js.map +1 -0
  116. package/dist/tools/readonly.d.ts +52 -0
  117. package/dist/tools/readonly.d.ts.map +1 -0
  118. package/dist/tools/readonly.js +638 -0
  119. package/dist/tools/readonly.js.map +1 -0
  120. package/dist/tools/selfcheck.d.ts +5 -0
  121. package/dist/tools/selfcheck.d.ts.map +1 -0
  122. package/dist/tools/selfcheck.js +91 -0
  123. package/dist/tools/selfcheck.js.map +1 -0
  124. package/dist/types.d.ts +66 -0
  125. package/dist/types.d.ts.map +1 -0
  126. package/dist/types.js +2 -0
  127. package/dist/types.js.map +1 -0
  128. package/docs/README.md +11 -0
  129. package/docs/SPEC.md +964 -0
  130. package/docs/decisions/ADR-001-use-spec-v1-architecture.md +25 -0
  131. package/docs/decisions/ADR-002-use-capability-based-generic-svn-policy.md +31 -0
  132. package/docs/decisions/ADR-003-use-plug-and-play-global-registration.md +39 -0
  133. package/docs/decisions/ADR-004-borrow-diagnostics-not-external-mcp-surface.md +39 -0
  134. package/docs/svnrules.md +84 -0
  135. package/docs/toolrules.md +34 -0
  136. package/jest.config.cjs +14 -0
  137. package/package.json +51 -0
  138. package/scripts/clean.mjs +27 -0
  139. package/scripts/prepare-release.mjs +84 -0
  140. package/src/envelope.ts +182 -0
  141. package/src/eol.ts +137 -0
  142. package/src/guards.ts +362 -0
  143. package/src/index.ts +364 -0
  144. package/src/parse/commitText.ts +9 -0
  145. package/src/parse/diffText.ts +66 -0
  146. package/src/parse/infoXml.ts +51 -0
  147. package/src/parse/logXml.ts +64 -0
  148. package/src/parse/statusXml.ts +87 -0
  149. package/src/parse/updateText.ts +76 -0
  150. package/src/runner.ts +344 -0
  151. package/src/tools/composite.ts +289 -0
  152. package/src/tools/diagnose.ts +150 -0
  153. package/src/tools/mutating.ts +694 -0
  154. package/src/tools/readonly.ts +786 -0
  155. package/src/tools/selfcheck.ts +97 -0
  156. package/src/types.ts +76 -0
  157. package/tests/README.md +9 -0
  158. package/tests/diff-redaction.test.ts +146 -0
  159. package/tests/entrypoint.test.ts +49 -0
  160. package/tests/guards-eol.test.ts +170 -0
  161. package/tests/integration-svn.test.ts +709 -0
  162. package/tests/parsers.test.ts +119 -0
  163. package/tests/runner.test.ts +159 -0
  164. package/tests/selfcheck.test.ts +22 -0
  165. package/tsconfig.json +30 -0
package/src/index.ts ADDED
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import fs from "node:fs";
6
+ import { pathToFileURL } from "node:url";
7
+ import * as z from "zod/v4";
8
+ import { toToolResult } from "./envelope.js";
9
+ import { readonlyMode as isReadonlyMode } from "./guards.js";
10
+ import { startupProbe } from "./runner.js";
11
+ import { eolFixVerified, svnPrecommit } from "./tools/composite.js";
12
+ import { svnDiagnose } from "./tools/diagnose.js";
13
+ import { eolCheck, svnDiff, svnInfo, svnLog, svnPropget, svnStatus } from "./tools/readonly.js";
14
+ import { svnSelfCheck } from "./tools/selfcheck.js";
15
+ import {
16
+ svnAdd,
17
+ svnCleanup,
18
+ svnCommit,
19
+ svnCopy,
20
+ svnExport,
21
+ svnImport,
22
+ svnMove,
23
+ svnPropset,
24
+ svnPropsetEolStyle,
25
+ svnRename,
26
+ svnResolved,
27
+ svnRevert,
28
+ svnUpdate
29
+ } from "./tools/mutating.js";
30
+
31
+ export const serverName = "svn";
32
+ export const serverVersion = "1.0.0";
33
+ export const readonlyMode = isReadonlyMode();
34
+
35
+ export function createServer(): McpServer {
36
+ const server = new McpServer({
37
+ name: serverName,
38
+ version: serverVersion
39
+ });
40
+
41
+ const cwd = z.string().optional().describe("Absolute working directory. If omitted, absolute paths identify their SVN working copy; relative paths require explicit cwd.");
42
+ const paths = z.array(z.string()).describe("Explicit paths relative to cwd or absolute paths inside one SVN working copy.");
43
+ const optionalPaths = z.array(z.string()).optional().describe("Optional paths relative to cwd or absolute paths inside one SVN working copy.");
44
+ const revision = z.string().regex(/^(?:\d+|HEAD|BASE|COMMITTED|PREV|\{[^}\r\n]+\})$/i).optional();
45
+ const propertyName = z.string().regex(/^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/);
46
+
47
+ server.registerTool(
48
+ "svn_self_check",
49
+ {
50
+ description: "Report MCP release pointer, bundled runtime payload counts, startup probe, and packaging script health.",
51
+ inputSchema: { cwd }
52
+ },
53
+ async (args) => toToolResult(await svnSelfCheck(compactArgs(args)))
54
+ );
55
+
56
+ server.registerTool(
57
+ "svn_diagnose",
58
+ {
59
+ description: "Read-only working-copy diagnostics for local status, remote status, HEAD info, and latest log reachability.",
60
+ inputSchema: { cwd, paths: optionalPaths }
61
+ },
62
+ async (args) => toToolResult(await svnDiagnose(compactArgs(args)))
63
+ );
64
+
65
+ server.registerTool(
66
+ "svn_status",
67
+ {
68
+ description: "Run scoped svn status and return structured changed paths and conflicts.",
69
+ inputSchema: { cwd, paths: optionalPaths, includeIgnored: z.boolean().optional(), hideNoise: z.boolean().optional() }
70
+ },
71
+ async (args) => toToolResult(await svnStatus(compactArgs(args)))
72
+ );
73
+
74
+ server.registerTool(
75
+ "svn_info",
76
+ {
77
+ description: "Run svn info and detect mixed-revision working-copy state.",
78
+ inputSchema: { cwd, paths: optionalPaths }
79
+ },
80
+ async (args) => toToolResult(await svnInfo(compactArgs(args)))
81
+ );
82
+
83
+ server.registerTool(
84
+ "svn_diff",
85
+ {
86
+ description: "Run scoped svn diff; ignore-EOL is enabled by default with internal diff and returns per-file counts.",
87
+ inputSchema: {
88
+ cwd,
89
+ paths,
90
+ ignoreEol: z.boolean().optional(),
91
+ lineLimit: z.number().int().positive().optional()
92
+ }
93
+ },
94
+ async (args) => toToolResult(await svnDiff(compactArgs(args)))
95
+ );
96
+
97
+ server.registerTool(
98
+ "svn_log",
99
+ {
100
+ description: "Run svn log and return structured log entries.",
101
+ inputSchema: {
102
+ cwd,
103
+ paths: optionalPaths,
104
+ limit: z.number().int().positive().optional(),
105
+ verbose: z.boolean().optional()
106
+ }
107
+ },
108
+ async (args) => toToolResult(await svnLog(compactArgs(args)))
109
+ );
110
+
111
+ server.registerTool(
112
+ "eol_check",
113
+ {
114
+ description: "Inspect EOL kind, BOM, and svn:eol-style for explicit files.",
115
+ inputSchema: { cwd, paths }
116
+ },
117
+ async (args) => toToolResult(await eolCheck(compactArgs(args)))
118
+ );
119
+
120
+ server.registerTool(
121
+ "svn_propget",
122
+ {
123
+ description: "Read one working-copy property from explicit paths.",
124
+ inputSchema: {
125
+ cwd,
126
+ paths,
127
+ name: propertyName
128
+ }
129
+ },
130
+ async (args) => toToolResult(await svnPropget(compactArgs(args)))
131
+ );
132
+
133
+ server.registerTool(
134
+ "svn_precommit",
135
+ {
136
+ description: "Composite read-only precommit check: status, ignore-EOL diff, EOL check, guard dry-run, and mixed-revision warning.",
137
+ inputSchema: {
138
+ cwd,
139
+ paths,
140
+ lineLimit: z.number().int().positive().optional()
141
+ }
142
+ },
143
+ async (args) => toToolResult(await svnPrecommit(compactArgs(args)))
144
+ );
145
+
146
+ server.registerTool(
147
+ "eol_fix_verified",
148
+ {
149
+ description: "Convert one file's EOL with unix2dos/dos2unix inferred from svn:eol-style, then verify the ignored-EOL diff.",
150
+ inputSchema: {
151
+ cwd,
152
+ path: z.string(),
153
+ target: z.enum(["crlf", "lf"]).optional(),
154
+ removeBom: z.boolean().optional(),
155
+ dryRun: z.boolean().optional(),
156
+ allowLarge: z.boolean().optional()
157
+ }
158
+ },
159
+ async (args) => toToolResult(await eolFixVerified(compactArgs(args)))
160
+ );
161
+
162
+ server.registerTool(
163
+ "svn_add",
164
+ {
165
+ description: "Guarded svn add for explicit files, adding needed parent directories, or explicitly recursive directories.",
166
+ inputSchema: { cwd, paths, allowRecursive: z.boolean().optional() }
167
+ },
168
+ async (args) => toToolResult(await svnAdd(compactArgs(args)))
169
+ );
170
+
171
+ server.registerTool(
172
+ "svn_commit",
173
+ {
174
+ description: "Guarded svn commit using a temp -F message file and explicit paths.",
175
+ inputSchema: {
176
+ cwd,
177
+ paths,
178
+ message: z.string(),
179
+ riskAck: z.boolean().optional()
180
+ }
181
+ },
182
+ async (args) => toToolResult(await svnCommit(compactArgs(args)))
183
+ );
184
+
185
+ server.registerTool(
186
+ "svn_move",
187
+ {
188
+ description: "Guarded working-copy svn move with parent directory creation.",
189
+ inputSchema: { cwd, src: z.string(), dest: z.string() }
190
+ },
191
+ async (args) => toToolResult(await svnMove(compactArgs(args)))
192
+ );
193
+
194
+ server.registerTool(
195
+ "svn_rename",
196
+ {
197
+ description: "Alias for guarded working-copy svn move/rename with parent directory creation.",
198
+ inputSchema: { cwd, src: z.string(), dest: z.string() }
199
+ },
200
+ async (args) => toToolResult(await svnRename(compactArgs(args)))
201
+ );
202
+
203
+ server.registerTool(
204
+ "svn_copy",
205
+ {
206
+ description: "Guarded working-copy svn copy with parent directory creation.",
207
+ inputSchema: { cwd, src: z.string(), dest: z.string() }
208
+ },
209
+ async (args) => toToolResult(await svnCopy(compactArgs(args)))
210
+ );
211
+
212
+ server.registerTool(
213
+ "svn_update",
214
+ {
215
+ description: "Guarded svn update with --accept postpone.",
216
+ inputSchema: { cwd, paths: optionalPaths, updateAll: z.boolean().optional() }
217
+ },
218
+ async (args) => toToolResult(await svnUpdate(compactArgs(args)))
219
+ );
220
+
221
+ server.registerTool(
222
+ "svn_revert",
223
+ {
224
+ description: "Preview or perform guarded svn revert for explicit paths.",
225
+ inputSchema: {
226
+ cwd,
227
+ paths,
228
+ allowRecursive: z.boolean().optional(),
229
+ dryRun: z.boolean().optional()
230
+ }
231
+ },
232
+ async (args) => toToolResult(await svnRevert(compactArgs(args)))
233
+ );
234
+
235
+ server.registerTool(
236
+ "svn_resolved",
237
+ {
238
+ description: "Resolve one conflicted path with an explicit accept mode.",
239
+ inputSchema: {
240
+ cwd,
241
+ path: z.string(),
242
+ accept: z.enum(["working", "mine-full", "theirs-full", "base"])
243
+ }
244
+ },
245
+ async (args) => toToolResult(await svnResolved(compactArgs(args)))
246
+ );
247
+
248
+ server.registerTool(
249
+ "svn_cleanup",
250
+ {
251
+ description: "Run svn cleanup without destructive cleanup flags.",
252
+ inputSchema: { cwd, path: z.string().optional() }
253
+ },
254
+ async (args) => toToolResult(await svnCleanup(compactArgs(args)))
255
+ );
256
+
257
+ server.registerTool(
258
+ "svn_propset_eol_style",
259
+ {
260
+ description: "Set svn:eol-style only for explicit files whose property is missing or mismatched.",
261
+ inputSchema: {
262
+ cwd,
263
+ paths,
264
+ style: z.enum(["native", "LF", "CRLF"]).optional()
265
+ }
266
+ },
267
+ async (args) => toToolResult(await svnPropsetEolStyle(compactArgs(args)))
268
+ );
269
+
270
+ server.registerTool(
271
+ "svn_propset",
272
+ {
273
+ description: "Guarded working-copy svn propset for explicit paths.",
274
+ inputSchema: {
275
+ cwd,
276
+ paths,
277
+ name: propertyName,
278
+ value: z.string().max(16000),
279
+ riskAck: z.boolean().optional()
280
+ }
281
+ },
282
+ async (args) => toToolResult(await svnPropset(compactArgs(args)))
283
+ );
284
+
285
+ server.registerTool(
286
+ "svn_export",
287
+ {
288
+ description: "Run explicit svn export.",
289
+ inputSchema: {
290
+ cwd,
291
+ src: z.string(),
292
+ dest: z.string(),
293
+ revision
294
+ }
295
+ },
296
+ async (args) => toToolResult(await svnExport(compactArgs(args)))
297
+ );
298
+
299
+ server.registerTool(
300
+ "svn_import",
301
+ {
302
+ description: "Run svn import with a temp -F message file.",
303
+ inputSchema: {
304
+ cwd,
305
+ src: z.string(),
306
+ url: z.string(),
307
+ message: z.string()
308
+ }
309
+ },
310
+ async (args) => toToolResult(await svnImport(compactArgs(args)))
311
+ );
312
+
313
+ return server;
314
+ }
315
+
316
+ type CompactArgs<T extends Record<string, unknown>> = {
317
+ [K in keyof T as undefined extends T[K] ? never : K]: T[K];
318
+ } & {
319
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
320
+ };
321
+
322
+ function compactArgs<T extends Record<string, unknown>>(args: T): CompactArgs<T> {
323
+ return Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined)) as CompactArgs<T>;
324
+ }
325
+
326
+ export async function main(): Promise<void> {
327
+ const probe = await startupProbe();
328
+ if (!probe.svn.ok) {
329
+ console.error(`svn-agent starting with unavailable svn: ${probe.svn.note}`);
330
+ }
331
+ if (!probe.dos2unix.ok || !probe.unix2dos.ok) {
332
+ console.error("svn-agent starting with unavailable EOL converter; eol_fix_verified may fail");
333
+ }
334
+
335
+ const server = createServer();
336
+ await server.connect(new StdioServerTransport());
337
+ console.error(`svn MCP ${serverVersion} running on stdio${isReadonlyMode() ? " (READONLY)" : ""}`);
338
+ }
339
+
340
+ // Node resolves the ESM entry module through junctions/symlinks, so when the
341
+ // server is launched via the documented `current` junction, import.meta.url
342
+ // points at releases\v<version>\... while argv[1] keeps the junction path.
343
+ // Compare real paths so both launch forms start the server.
344
+ const launchedDirectly = (() => {
345
+ const entry = process.argv[1];
346
+ if (entry === undefined) {
347
+ return false;
348
+ }
349
+ if (import.meta.url === pathToFileURL(entry).href) {
350
+ return true;
351
+ }
352
+ try {
353
+ return import.meta.url === pathToFileURL(fs.realpathSync.native(entry)).href;
354
+ } catch {
355
+ return false;
356
+ }
357
+ })();
358
+
359
+ if (launchedDirectly) {
360
+ main().catch((error: unknown) => {
361
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
362
+ process.exit(1);
363
+ });
364
+ }
@@ -0,0 +1,9 @@
1
+ export function parseCommittedRevision(text: string): number | null {
2
+ const match = /Committed revision (\d+)\./i.exec(text);
3
+ if (!match) {
4
+ return null;
5
+ }
6
+
7
+ const revision = Number.parseInt(match[1] ?? "", 10);
8
+ return Number.isFinite(revision) ? revision : null;
9
+ }
@@ -0,0 +1,66 @@
1
+ import type { DiffFileSummary, DiffSummary } from "../types.js";
2
+
3
+ export function parseDiffText(text: string, lineLimit: number): DiffSummary {
4
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5
+ const lines = normalized ? normalized.split("\n") : [];
6
+ const accumulator = createDiffAccumulator(lineLimit);
7
+
8
+ for (const line of lines) {
9
+ accumulator.pushLine(line);
10
+ }
11
+
12
+ return accumulator.summary();
13
+ }
14
+
15
+ export function createDiffAccumulator(lineLimit: number): {
16
+ pushLine: (line: string) => void;
17
+ summary: () => DiffSummary;
18
+ } {
19
+ const perFile = new Map<string, DiffFileSummary>();
20
+ let current: DiffFileSummary | null = null;
21
+ const excerptLines: string[] = [];
22
+ let totalLines = 0;
23
+
24
+ return {
25
+ pushLine(line: string): void {
26
+ totalLines += 1;
27
+ if (excerptLines.length < lineLimit) {
28
+ excerptLines.push(line);
29
+ }
30
+
31
+ const indexMatch = /^Index: (.+)$/.exec(line);
32
+ if (indexMatch) {
33
+ current = {
34
+ path: indexMatch[1] ?? "",
35
+ added: 0,
36
+ removed: 0,
37
+ binary: false
38
+ };
39
+ perFile.set(current.path, current);
40
+ return;
41
+ }
42
+
43
+ if (!current) {
44
+ return;
45
+ }
46
+
47
+ if (/^(?:Cannot display: file marked as a binary type\.|Binary files differ\.)$/i.test(line)) {
48
+ current.binary = true;
49
+ return;
50
+ }
51
+
52
+ if (line.startsWith("+") && !line.startsWith("+++")) {
53
+ current.added += 1;
54
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
55
+ current.removed += 1;
56
+ }
57
+ },
58
+ summary(): DiffSummary {
59
+ return {
60
+ per_file: [...perFile.values()],
61
+ diff_excerpt: excerptLines.join("\n"),
62
+ truncated: totalLines > lineLimit
63
+ };
64
+ }
65
+ };
66
+ }
@@ -0,0 +1,51 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { WcInfo } from "../types.js";
3
+
4
+ const parser = new XMLParser({
5
+ ignoreAttributes: false,
6
+ attributeNamePrefix: "",
7
+ textNodeName: "text"
8
+ });
9
+
10
+ export function parseInfoXml(xml: string): WcInfo[] {
11
+ if (!xml.trim()) {
12
+ return [];
13
+ }
14
+
15
+ const parsed = parser.parse(xml) as {
16
+ info?: {
17
+ entry?: unknown;
18
+ };
19
+ };
20
+
21
+ return asArray(parsed.info?.entry).map((entry) => {
22
+ const entryObj = entry as {
23
+ revision?: string | number;
24
+ url?: string;
25
+ repository?: { root?: string };
26
+ "wc-info"?: { "wcroot-abspath"?: string };
27
+ };
28
+
29
+ return {
30
+ url: entryObj.url ?? null,
31
+ repo_root: entryObj.repository?.root ?? null,
32
+ wc_root: entryObj["wc-info"]?.["wcroot-abspath"] ?? null,
33
+ revision: parseRevision(entryObj.revision)
34
+ };
35
+ });
36
+ }
37
+
38
+ function parseRevision(value: string | number | undefined): number | null {
39
+ if (value === undefined) {
40
+ return null;
41
+ }
42
+ const parsed = Number.parseInt(String(value), 10);
43
+ return Number.isFinite(parsed) ? parsed : null;
44
+ }
45
+
46
+ function asArray<T>(value: T | T[] | undefined): T[] {
47
+ if (value === undefined) {
48
+ return [];
49
+ }
50
+ return Array.isArray(value) ? value : [value];
51
+ }
@@ -0,0 +1,64 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { ChangedPath } from "../types.js";
3
+
4
+ const parser = new XMLParser({
5
+ ignoreAttributes: false,
6
+ attributeNamePrefix: "",
7
+ textNodeName: "text"
8
+ });
9
+
10
+ export interface LogEntry {
11
+ rev: number;
12
+ author: string;
13
+ date: string;
14
+ msg: string;
15
+ changed_paths: ChangedPath[];
16
+ }
17
+
18
+ export function parseLogXml(xml: string): LogEntry[] {
19
+ if (!xml.trim()) {
20
+ return [];
21
+ }
22
+
23
+ const parsed = parser.parse(xml) as {
24
+ log?: {
25
+ logentry?: unknown;
26
+ };
27
+ };
28
+
29
+ return asArray(parsed.log?.logentry).map((entry) => {
30
+ const entryObj = entry as {
31
+ revision?: string | number;
32
+ author?: string;
33
+ date?: string;
34
+ msg?: string;
35
+ paths?: { path?: unknown };
36
+ };
37
+
38
+ return {
39
+ rev: parseNumber(entryObj.revision),
40
+ author: entryObj.author ?? "",
41
+ date: entryObj.date ?? "",
42
+ msg: entryObj.msg ?? "",
43
+ changed_paths: asArray(entryObj.paths?.path).map((pathEntry) => {
44
+ const pathObj = pathEntry as { action?: string; text?: string };
45
+ return {
46
+ status: pathObj.action ?? "",
47
+ path: pathObj.text ?? ""
48
+ };
49
+ })
50
+ };
51
+ });
52
+ }
53
+
54
+ function parseNumber(value: string | number | undefined): number {
55
+ const parsed = Number.parseInt(String(value ?? "0"), 10);
56
+ return Number.isFinite(parsed) ? parsed : 0;
57
+ }
58
+
59
+ function asArray<T>(value: T | T[] | undefined): T[] {
60
+ if (value === undefined) {
61
+ return [];
62
+ }
63
+ return Array.isArray(value) ? value : [value];
64
+ }
@@ -0,0 +1,87 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { ChangedPath, Conflict } from "../types.js";
3
+
4
+ const parser = new XMLParser({
5
+ ignoreAttributes: false,
6
+ attributeNamePrefix: "",
7
+ textNodeName: "text"
8
+ });
9
+
10
+ const statusMap: Record<string, string> = {
11
+ modified: "M",
12
+ added: "A",
13
+ deleted: "D",
14
+ replaced: "R",
15
+ conflicted: "C",
16
+ unversioned: "?",
17
+ missing: "!",
18
+ incomplete: "!",
19
+ obstructed: "~",
20
+ normal: "",
21
+ none: ""
22
+ };
23
+
24
+ export function parseStatusXml(xml: string): { changed_paths: ChangedPath[]; conflicts: Conflict[] } {
25
+ if (!xml.trim()) {
26
+ return { changed_paths: [], conflicts: [] };
27
+ }
28
+
29
+ const parsed = parser.parse(xml) as {
30
+ status?: {
31
+ target?: unknown;
32
+ };
33
+ };
34
+
35
+ const targets = asArray(parsed.status?.target);
36
+ const changed_paths: ChangedPath[] = [];
37
+ const conflicts: Conflict[] = [];
38
+
39
+ for (const target of targets) {
40
+ const targetObj = target as { entry?: unknown };
41
+ for (const entry of asArray(targetObj.entry)) {
42
+ const entryObj = entry as {
43
+ path?: string;
44
+ "wc-status"?: {
45
+ item?: string;
46
+ props?: string;
47
+ "tree-conflicted"?: string | boolean;
48
+ };
49
+ };
50
+ const item = entryObj["wc-status"]?.item ?? "";
51
+ const props = entryObj["wc-status"]?.props ?? "";
52
+ let status = statusMap[item] ?? item.slice(0, 1).toUpperCase();
53
+ const path = entryObj.path ?? "";
54
+
55
+ if (!status && props === "modified") {
56
+ status = "_M";
57
+ }
58
+ if (props === "conflicted") {
59
+ status = status || "C";
60
+ }
61
+
62
+ if (status) {
63
+ changed_paths.push({ status, path });
64
+ }
65
+
66
+ if (item === "conflicted") {
67
+ conflicts.push({ path, type: "text" });
68
+ }
69
+ if (props === "conflicted") {
70
+ conflicts.push({ path, type: "prop" });
71
+ }
72
+
73
+ if (entryObj["wc-status"]?.["tree-conflicted"] === "true" || entryObj["wc-status"]?.["tree-conflicted"] === true) {
74
+ conflicts.push({ path, type: "tree" });
75
+ }
76
+ }
77
+ }
78
+
79
+ return { changed_paths, conflicts };
80
+ }
81
+
82
+ function asArray<T>(value: T | T[] | undefined): T[] {
83
+ if (value === undefined) {
84
+ return [];
85
+ }
86
+ return Array.isArray(value) ? value : [value];
87
+ }