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
@@ -0,0 +1,76 @@
1
+ import type { ChangedPath, Conflict } from "../types.js";
2
+
3
+ export function parseUpdateText(text: string): { changed_paths: ChangedPath[]; conflicts: Conflict[] } {
4
+ const changed_paths: ChangedPath[] = [];
5
+ const conflicts: Conflict[] = [];
6
+ let treeConflictSummaryCount = 0;
7
+
8
+ for (const rawLine of text.replace(/\r\n/g, "\n").split("\n")) {
9
+ const line = rawLine.trimEnd();
10
+ if (!line) {
11
+ continue;
12
+ }
13
+
14
+ const summaryMatch = /^\s*Tree conflicts:\s*(\d+)/i.exec(line);
15
+ if (summaryMatch) {
16
+ treeConflictSummaryCount = Number.parseInt(summaryMatch[1] ?? "0", 10);
17
+ continue;
18
+ }
19
+
20
+ const columns = line.slice(0, 5).padEnd(5, " ");
21
+ const path = line.slice(5).trim();
22
+ if (!isUpdateStatusColumns(columns)) {
23
+ continue;
24
+ }
25
+ const contentStatus = columns[0] ?? " ";
26
+ const propStatus = columns[1] ?? " ";
27
+ const treeStatus = columns[3] ?? " ";
28
+ const status = firstStatus([contentStatus, propStatus, treeStatus]);
29
+ if (!status || !path) {
30
+ continue;
31
+ }
32
+
33
+ changed_paths.push({ status, path });
34
+ if (contentStatus === "C") {
35
+ conflicts.push({ path, type: "text" });
36
+ }
37
+ if (propStatus === "C") {
38
+ conflicts.push({ path, type: "prop" });
39
+ }
40
+ if (treeStatus === "C") {
41
+ conflicts.push({ path, type: "tree" });
42
+ }
43
+ }
44
+
45
+ const detailedTreeConflicts = conflicts.filter((conflict) => conflict.type === "tree").length;
46
+ for (let index = detailedTreeConflicts; index < treeConflictSummaryCount; index += 1) {
47
+ conflicts.push({ path: "", type: "tree" });
48
+ }
49
+
50
+ return { changed_paths, conflicts };
51
+ }
52
+
53
+ // Real update-status lines use four status columns plus a separator space
54
+ // before the path. Informational lines ("Updated to revision 42.",
55
+ // "Updating '.':", "At revision 42.") fail this shape and must not become
56
+ // phantom changed paths.
57
+ function isUpdateStatusColumns(columns: string): boolean {
58
+ if ((columns[4] ?? " ") !== " ") {
59
+ return false;
60
+ }
61
+ for (let index = 0; index < 4; index += 1) {
62
+ if (!/^[UGCEADMRB~! ]$/.test(columns[index] ?? " ")) {
63
+ return false;
64
+ }
65
+ }
66
+ return true;
67
+ }
68
+
69
+ function firstStatus(columns: string[]): string | null {
70
+ for (const status of columns) {
71
+ if (/^[UGCEADMR~!]$/.test(status)) {
72
+ return status;
73
+ }
74
+ }
75
+ return null;
76
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,344 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { isUtf8 } from "node:buffer";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { redactArgv } from "./envelope.js";
7
+ import { readonlyMode } from "./guards.js";
8
+ import type { RunResult } from "./types.js";
9
+
10
+ const DEFAULT_TIMEOUT_MS = 120000;
11
+ const DEFAULT_MAX_BUFFER = 20 * 1024 * 1024;
12
+
13
+ export function timeoutMs(): number {
14
+ const parsed = Number.parseInt(process.env.SVN_AGENT_TIMEOUT_MS ?? "", 10);
15
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
16
+ }
17
+
18
+ export function svnExecutable(): string {
19
+ return process.env.SVN_AGENT_SVN_PATH || bundledExecutable("svn") || "svn";
20
+ }
21
+
22
+ export function svnVersionExecutable(): string {
23
+ const svnPath = process.env.SVN_AGENT_SVN_PATH;
24
+ if (!svnPath || svnPath === "svn") {
25
+ return bundledExecutable("svnversion") || "svnversion";
26
+ }
27
+
28
+ return path.join(path.dirname(svnPath), "svnversion.exe");
29
+ }
30
+
31
+ export function svnAdminExecutable(): string {
32
+ const svnPath = process.env.SVN_AGENT_SVN_PATH;
33
+ if (!svnPath || svnPath === "svn") {
34
+ return bundledExecutable("svnadmin") || "svnadmin";
35
+ }
36
+
37
+ return path.join(path.dirname(svnPath), "svnadmin.exe");
38
+ }
39
+
40
+ export function dos2UnixExecutable(name: "dos2unix" | "unix2dos"): string {
41
+ const dir = process.env.SVN_AGENT_DOS2UNIX_DIR;
42
+ if (dir) {
43
+ return path.join(dir, `${name}.exe`);
44
+ }
45
+
46
+ return bundledExecutable(name) || name;
47
+ }
48
+
49
+ function bundledExecutable(name: string): string | null {
50
+ for (const dir of bundledBinDirs()) {
51
+ for (const candidate of executableCandidates(dir, name)) {
52
+ if (fs.existsSync(candidate)) {
53
+ return candidate;
54
+ }
55
+ }
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ function bundledBinDirs(): string[] {
62
+ const dirs = [
63
+ process.env.SVN_AGENT_BIN_DIR ? path.resolve(process.env.SVN_AGENT_BIN_DIR) : null,
64
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "bin")
65
+ ].filter((dir): dir is string => dir !== null);
66
+
67
+ return Array.from(new Set(dirs));
68
+ }
69
+
70
+ function executableCandidates(dir: string, name: string): string[] {
71
+ const suffix = process.platform === "win32" ? ".exe" : "";
72
+ const primary = path.join(dir, `${name}${suffix}`);
73
+ const fallback = path.join(dir, name);
74
+ return primary === fallback ? [primary] : [primary, fallback];
75
+ }
76
+
77
+ export async function runExecutable(
78
+ executable: string,
79
+ args: string[],
80
+ options: { cwd: string; timeout?: number; maxBuffer?: number }
81
+ ): Promise<RunResult> {
82
+ const cwd = path.resolve(options.cwd);
83
+ const command = redactArgv(executable, args);
84
+
85
+ return new Promise((resolve) => {
86
+ execFile(
87
+ executable,
88
+ args,
89
+ {
90
+ cwd,
91
+ env: stableToolEnv(),
92
+ timeout: options.timeout ?? timeoutMs(),
93
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
94
+ windowsHide: true,
95
+ encoding: "buffer"
96
+ },
97
+ (error, stdoutBuffer, stderrBuffer) => {
98
+ const stdout = decodeOutput(stdoutBuffer);
99
+ const stderr = decodeOutput(stderrBuffer);
100
+ const execError = error as (NodeJS.ErrnoException & { killed?: boolean; signal?: NodeJS.Signals }) | null;
101
+ const killedByTimeout =
102
+ Boolean(execError?.killed) && (execError?.signal === "SIGTERM" || execError?.code === "ETIMEDOUT");
103
+
104
+ const result: RunResult = {
105
+ command,
106
+ cwd,
107
+ executable,
108
+ args,
109
+ exitCode: typeof execError?.code === "number" ? execError.code : execError ? 1 : 0,
110
+ signal: (execError?.signal as NodeJS.Signals | null | undefined) ?? null,
111
+ stdout,
112
+ stderr,
113
+ timedOut: killedByTimeout
114
+ };
115
+ if (typeof execError?.code === "string") {
116
+ result.errorCode = execError.code;
117
+ }
118
+ resolve(result);
119
+ }
120
+ );
121
+ });
122
+ }
123
+
124
+ export async function runSvn(args: string[], cwd: string): Promise<RunResult> {
125
+ return runExecutable(svnExecutable(), nonInteractiveSvnArgs(args), { cwd });
126
+ }
127
+
128
+ export async function runSvnStreamingLines(
129
+ args: string[],
130
+ cwd: string,
131
+ onStdoutLine: (line: string) => void,
132
+ options: { stdoutLineLimit?: number; timeout?: number } = {}
133
+ ): Promise<RunResult> {
134
+ return runExecutableStreamingLines(svnExecutable(), nonInteractiveSvnArgs(args), { cwd, ...options }, onStdoutLine);
135
+ }
136
+
137
+ export async function runExecutableStreamingLines(
138
+ executable: string,
139
+ args: string[],
140
+ options: { cwd: string; stdoutLineLimit?: number; timeout?: number; stderrMaxBuffer?: number },
141
+ onStdoutLine: (line: string) => void
142
+ ): Promise<RunResult> {
143
+ const cwd = path.resolve(options.cwd);
144
+ const command = redactArgv(executable, args);
145
+ const stdoutLineLimit = options.stdoutLineLimit ?? 200;
146
+
147
+ return new Promise((resolve) => {
148
+ const child = spawn(executable, args, {
149
+ cwd,
150
+ env: stableToolEnv(),
151
+ windowsHide: true,
152
+ stdio: ["ignore", "pipe", "pipe"]
153
+ });
154
+ const stdoutLines: string[] = [];
155
+ const stderrBuffers: Buffer[] = [];
156
+ const stderrMaxBuffer = options.stderrMaxBuffer ?? DEFAULT_MAX_BUFFER;
157
+ let stdoutCarry: Buffer = Buffer.alloc(0);
158
+ let timedOut = false;
159
+ let settled = false;
160
+ let stderrBytes = 0;
161
+ let stderrTruncated = false;
162
+
163
+ const timeout = setTimeout(() => {
164
+ timedOut = true;
165
+ child.kill("SIGTERM");
166
+ settle({
167
+ exitCode: null,
168
+ signal: "SIGTERM",
169
+ timedOut: true
170
+ });
171
+ }, options.timeout ?? timeoutMs());
172
+
173
+ child.stdout.on("data", (chunk: Buffer) => {
174
+ if (settled) {
175
+ return;
176
+ }
177
+ consumeStdout(chunk);
178
+ });
179
+ child.stderr.on("data", (chunk: Buffer) => {
180
+ if (settled) {
181
+ return;
182
+ }
183
+ appendStderr(chunk);
184
+ });
185
+
186
+ child.on("error", (error: NodeJS.ErrnoException) => {
187
+ settle({
188
+ exitCode: 1,
189
+ signal: null,
190
+ stderrOverride: error.message,
191
+ timedOut,
192
+ errorCode: error.code
193
+ });
194
+ });
195
+
196
+ child.on("close", (code, signal) => {
197
+ settle({ exitCode: code, signal, timedOut });
198
+ });
199
+
200
+ function settle(input: {
201
+ exitCode: number | null;
202
+ signal: NodeJS.Signals | null;
203
+ timedOut: boolean;
204
+ stderrOverride?: string | undefined;
205
+ errorCode?: string | undefined;
206
+ }): void {
207
+ if (settled) {
208
+ return;
209
+ }
210
+ settled = true;
211
+ clearTimeout(timeout);
212
+ consumeStdout(Buffer.alloc(0), true);
213
+ const stderrText =
214
+ decodeOutput(Buffer.concat(stderrBuffers)) + (stderrTruncated ? "\n[stderr truncated]" : "");
215
+ resolve({
216
+ command,
217
+ cwd,
218
+ executable,
219
+ args,
220
+ exitCode: input.exitCode,
221
+ signal: input.signal,
222
+ stdout: stdoutLines.join("\n"),
223
+ stderr: input.stderrOverride ?? stderrText,
224
+ timedOut: input.timedOut,
225
+ errorCode: input.errorCode,
226
+ truncated: stderrTruncated
227
+ });
228
+ }
229
+
230
+ function appendStderr(chunk: Buffer): void {
231
+ if (stderrTruncated) {
232
+ return;
233
+ }
234
+
235
+ const remaining = stderrMaxBuffer - stderrBytes;
236
+ if (remaining <= 0) {
237
+ stderrTruncated = true;
238
+ return;
239
+ }
240
+
241
+ if (chunk.length > remaining) {
242
+ stderrBuffers.push(chunk.subarray(0, remaining));
243
+ stderrBytes = stderrMaxBuffer;
244
+ stderrTruncated = true;
245
+ return;
246
+ }
247
+
248
+ stderrBuffers.push(chunk);
249
+ stderrBytes += chunk.length;
250
+ }
251
+
252
+ // Byte-level line splitting so each line can take the latin1 fallback in
253
+ // decodeOutput; a text StringDecoder would lossily replace non-UTF8 bytes
254
+ // before the fallback could see them.
255
+ function consumeStdout(chunk: Buffer, flush = false): void {
256
+ if (chunk.length === 0 && !flush) {
257
+ return;
258
+ }
259
+
260
+ stdoutCarry = stdoutCarry.length === 0 ? chunk : Buffer.concat([stdoutCarry, chunk]);
261
+ let newlineIndex: number;
262
+ while ((newlineIndex = stdoutCarry.indexOf(0x0a)) !== -1) {
263
+ emitStdoutLine(stdoutCarry.subarray(0, newlineIndex));
264
+ stdoutCarry = stdoutCarry.subarray(newlineIndex + 1);
265
+ }
266
+ if (flush && stdoutCarry.length > 0) {
267
+ emitStdoutLine(stdoutCarry);
268
+ stdoutCarry = Buffer.alloc(0);
269
+ }
270
+ }
271
+
272
+ function emitStdoutLine(lineBytes: Buffer): void {
273
+ const trimmed = lineBytes.length > 0 && lineBytes[lineBytes.length - 1] === 0x0d
274
+ ? lineBytes.subarray(0, lineBytes.length - 1)
275
+ : lineBytes;
276
+ const line = decodeOutput(trimmed);
277
+ onStdoutLine(line);
278
+ if (stdoutLines.length < stdoutLineLimit) {
279
+ stdoutLines.push(line);
280
+ }
281
+ }
282
+ });
283
+ }
284
+
285
+ export async function runSvnVersion(target: string, cwd: string): Promise<RunResult> {
286
+ return runExecutable(svnVersionExecutable(), [target], { cwd });
287
+ }
288
+
289
+ export async function runDos2Unix(name: "dos2unix" | "unix2dos", args: string[], cwd: string): Promise<RunResult> {
290
+ return runExecutable(dos2UnixExecutable(name), args, { cwd });
291
+ }
292
+
293
+ export async function startupProbe(cwd = process.cwd()): Promise<{
294
+ readonly: boolean;
295
+ svn: { ok: boolean; version: string | null; note: string };
296
+ dos2unix: { ok: boolean; note: string };
297
+ unix2dos: { ok: boolean; note: string };
298
+ }> {
299
+ const svn = await runExecutable(svnExecutable(), ["--version", "--quiet"], { cwd, timeout: 10000 });
300
+ const dos2unix = await runExecutable(dos2UnixExecutable("dos2unix"), ["--version"], { cwd, timeout: 10000 });
301
+ const unix2dos = await runExecutable(dos2UnixExecutable("unix2dos"), ["--version"], { cwd, timeout: 10000 });
302
+
303
+ return {
304
+ readonly: readonlyMode(),
305
+ svn: {
306
+ ok: svn.exitCode === 0,
307
+ version: svn.exitCode === 0 ? svn.stdout.trim() : null,
308
+ note: svn.exitCode === 0 ? "" : "svn unavailable"
309
+ },
310
+ dos2unix: {
311
+ ok: dos2unix.exitCode === 0,
312
+ note: dos2unix.exitCode === 0 ? firstLine(dos2unix.stdout) : "dos2unix unavailable"
313
+ },
314
+ unix2dos: {
315
+ ok: unix2dos.exitCode === 0,
316
+ note: unix2dos.exitCode === 0 ? firstLine(unix2dos.stdout) : "unix2dos unavailable"
317
+ }
318
+ };
319
+ }
320
+
321
+ function firstLine(text: string): string {
322
+ return text.replace(/\r\n/g, "\n").split("\n")[0] ?? "";
323
+ }
324
+
325
+ function decodeOutput(buffer: Buffer | string): string {
326
+ if (typeof buffer === "string") {
327
+ return buffer;
328
+ }
329
+
330
+ return isUtf8(buffer) ? buffer.toString("utf8") : buffer.toString("latin1");
331
+ }
332
+
333
+ function nonInteractiveSvnArgs(args: string[]): string[] {
334
+ return args.includes("--non-interactive") ? args : ["--non-interactive", ...args];
335
+ }
336
+
337
+ function stableToolEnv(): NodeJS.ProcessEnv {
338
+ return {
339
+ ...process.env,
340
+ LC_ALL: "C",
341
+ LANG: "C",
342
+ LANGUAGE: "C"
343
+ };
344
+ }
@@ -0,0 +1,289 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createEnvelope, envelopeFromRun, failEnvelope, noteFromRun } from "../envelope.js";
4
+ import { converterForEolTarget, convertEol, isBinaryKind, normalizeEolTarget, sniffEol } from "../eol.js";
5
+ import {
6
+ assertExistingTargets,
7
+ neverCommitHit,
8
+ neverCommitNote,
9
+ readonlyMode,
10
+ repoRelativePath,
11
+ requireExplicitPaths,
12
+ resolveCwd,
13
+ resolveTargetsInsideWc
14
+ } from "../guards.js";
15
+ import { parseDiffText } from "../parse/diffText.js";
16
+ import { runSvn, runSvnVersion } from "../runner.js";
17
+ import type { ToolEnvelope } from "../types.js";
18
+ import {
19
+ defaultDiffLineLimit,
20
+ dryRiskSignals,
21
+ eolCheck,
22
+ getWcContext,
23
+ normalizeStatusLookup,
24
+ scopedStatusMap,
25
+ svnDiff
26
+ } from "./readonly.js";
27
+
28
+ export async function svnPrecommit(input: { cwd?: string; paths: string[]; lineLimit?: number }): Promise<ToolEnvelope> {
29
+ const explicitError = requireExplicitPaths(input.paths);
30
+ const cwd = resolveCwd(input.cwd);
31
+ if (explicitError) {
32
+ return {
33
+ ...failEnvelope("svn_precommit", cwd, explicitError),
34
+ verdict: "GUARD_BLOCKED",
35
+ per_file: [],
36
+ risk_signals: [],
37
+ diff_excerpt: ""
38
+ };
39
+ }
40
+
41
+ const context = await getWcContext(input.cwd, input.paths);
42
+ if (!context.ok) {
43
+ return {
44
+ ...context.envelope,
45
+ verdict: "GUARD_BLOCKED",
46
+ per_file: [],
47
+ risk_signals: [],
48
+ diff_excerpt: ""
49
+ };
50
+ }
51
+
52
+ const resolved = resolveTargetsInsideWc(context.cwd, context.wcRoot, input.paths);
53
+ if (!resolved.ok) {
54
+ return {
55
+ ...failEnvelope("svn_precommit", context.cwd, resolved.note),
56
+ verdict: "GUARD_BLOCKED",
57
+ per_file: [],
58
+ risk_signals: [],
59
+ diff_excerpt: ""
60
+ };
61
+ }
62
+
63
+ const status = await scopedStatusMap(context.cwd, context.wcRoot, input.paths);
64
+ if (!status.envelope.ok) {
65
+ return {
66
+ ...status.envelope,
67
+ verdict: "GUARD_BLOCKED",
68
+ per_file: [],
69
+ risk_signals: [],
70
+ diff_excerpt: ""
71
+ };
72
+ }
73
+
74
+ const diff = await svnDiff({
75
+ cwd: context.cwd,
76
+ paths: input.paths,
77
+ ignoreEol: true,
78
+ lineLimit: input.lineLimit ?? defaultDiffLineLimit()
79
+ });
80
+ const eol = await eolCheck({ cwd: context.cwd, paths: input.paths });
81
+ const eolFiles = new Map(
82
+ ((eol.files as Array<{ path: string }> | undefined) ?? []).map((file) => [path.resolve(file.path).toLowerCase(), file])
83
+ );
84
+ const diffFiles = new Map(diff.per_file.map((file) => [path.resolve(context.cwd, file.path).toLowerCase(), file]));
85
+ const riskSignals = dryRiskSignals(resolved.paths, context.wcRoot, status.map);
86
+ const perFile = [];
87
+ const guardNotes: string[] = [];
88
+ const diffNotes: string[] = [];
89
+ let hasRealChange = false;
90
+ let needsEolFix = false;
91
+
92
+ if (!diff.ok) {
93
+ diffNotes.push(`svn diff failed: ${diff.note || "unknown reason"}`);
94
+ if (diff.recovery_tool === "eol_fix_verified") {
95
+ needsEolFix = true;
96
+ }
97
+ }
98
+
99
+ for (const target of resolved.paths) {
100
+ const lower = target.toLowerCase();
101
+ const statusCode = normalizeStatusLookup(status.map, target);
102
+ const diffFile = diffFiles.get(lower);
103
+ const eolFile = eolFiles.get(lower) as
104
+ | { kind?: string; eol_style?: string | null; has_bom?: boolean; mismatch?: boolean }
105
+ | undefined;
106
+ const never = neverCommitHit(target, context.wcRoot);
107
+ const guard = never
108
+ ? neverCommitNote(never, target, context.wcRoot)
109
+ : !statusCode || statusCode === "?" || statusCode === "!" || statusCode === "I"
110
+ ? `path is not committable: ${repoRelativePath(target, context.wcRoot)}`
111
+ : null;
112
+
113
+ if (guard) {
114
+ guardNotes.push(guard);
115
+ }
116
+
117
+ const pureEolChurn = statusCode === "M" && !diffFile;
118
+ if (eolFile?.mismatch || pureEolChurn) {
119
+ needsEolFix = true;
120
+ }
121
+ if (statusCode && statusCode !== "?" && statusCode !== "!" && statusCode !== "I" && !pureEolChurn) {
122
+ hasRealChange = true;
123
+ }
124
+
125
+ perFile.push({
126
+ path: repoRelativePath(target, context.wcRoot),
127
+ status: statusCode ?? "",
128
+ added: diffFile?.added ?? 0,
129
+ removed: diffFile?.removed ?? 0,
130
+ eol: eolFile?.kind ?? null,
131
+ eol_style: eolFile?.eol_style ?? null,
132
+ bom: eolFile?.has_bom ?? false,
133
+ pure_eol_churn: pureEolChurn,
134
+ guard
135
+ });
136
+ }
137
+
138
+ const version = await runSvnVersion(context.cwd, context.cwd);
139
+ const mixedRevision = version.exitCode === 0 && version.stdout.includes(":");
140
+ const verdict = guardNotes.length > 0
141
+ ? "GUARD_BLOCKED"
142
+ : !diff.ok && diff.recovery_tool !== "eol_fix_verified"
143
+ ? "DIFF_FAILED"
144
+ : needsEolFix
145
+ ? "EOL_FIX_NEEDED"
146
+ : !hasRealChange
147
+ ? "NOTHING_TO_COMMIT"
148
+ : "READY";
149
+
150
+ const notes = [
151
+ verdict,
152
+ ...guardNotes,
153
+ ...diffNotes,
154
+ mixedRevision ? "mixed revision working copy" : ""
155
+ ].filter(Boolean);
156
+
157
+ return {
158
+ ...createEnvelope({
159
+ ok: verdict !== "GUARD_BLOCKED" && verdict !== "DIFF_FAILED",
160
+ command: "svn_precommit",
161
+ cwd: context.cwd,
162
+ changed_paths: status.envelope.changed_paths,
163
+ conflicts: status.envelope.conflicts,
164
+ truncated: diff.truncated,
165
+ note: notes.join("; ")
166
+ }),
167
+ verdict,
168
+ per_file: perFile,
169
+ risk_signals: riskSignals,
170
+ diff_excerpt: diff.diff_excerpt,
171
+ truncated: diff.truncated
172
+ };
173
+ }
174
+
175
+ export async function eolFixVerified(input: {
176
+ cwd?: string;
177
+ path: string;
178
+ target?: "crlf" | "lf";
179
+ removeBom?: boolean;
180
+ dryRun?: boolean;
181
+ allowLarge?: boolean;
182
+ }): Promise<ToolEnvelope> {
183
+ const cwd = resolveCwd(input.cwd);
184
+ if (readonlyMode()) {
185
+ return failEnvelope("eol_fix_verified", cwd, "READONLY instance");
186
+ }
187
+
188
+ const context = await getWcContext(input.cwd, [input.path]);
189
+ if (!context.ok) {
190
+ return context.envelope;
191
+ }
192
+
193
+ const resolved = resolveTargetsInsideWc(context.cwd, context.wcRoot, [input.path]);
194
+ if (!resolved.ok) {
195
+ return failEnvelope("eol_fix_verified", context.cwd, resolved.note);
196
+ }
197
+
198
+ const filePath = resolved.paths[0]!;
199
+ const existsError = assertExistingTargets([filePath]);
200
+ if (existsError) {
201
+ return failEnvelope("eol_fix_verified", context.cwd, existsError);
202
+ }
203
+ if (!pathIsFile(filePath)) {
204
+ return failEnvelope("eol_fix_verified", context.cwd, `path is not a file: ${repoRelativePath(filePath, context.wcRoot)}`);
205
+ }
206
+
207
+ const before = await sniffEol(filePath);
208
+ if (isBinaryKind(before.kind)) {
209
+ return {
210
+ ...failEnvelope("eol_fix_verified", context.cwd, "binary file refused"),
211
+ before
212
+ };
213
+ }
214
+ if (before.kind === "skipped-too-large" && !input.allowLarge) {
215
+ return {
216
+ ...failEnvelope("eol_fix_verified", context.cwd, "file too large for EOL repair without allowLarge:true"),
217
+ before
218
+ };
219
+ }
220
+
221
+ const prop = await runSvn(["propget", "svn:eol-style", filePath], context.cwd);
222
+ const eolStyle = prop.exitCode === 0 ? prop.stdout.trim() || null : null;
223
+ const target = normalizeEolTarget(input.target, eolStyle);
224
+ const converter = converterForEolTarget(target);
225
+ if (input.dryRun) {
226
+ return {
227
+ ...createEnvelope({
228
+ ok: true,
229
+ command: "eol_fix_verified",
230
+ cwd: context.cwd,
231
+ note: `dry run: would convert to ${target}`
232
+ }),
233
+ before,
234
+ after: before,
235
+ target,
236
+ eol_style: eolStyle,
237
+ converter,
238
+ pure_eol_churn: false
239
+ };
240
+ }
241
+
242
+ const conversion = await convertEol({
243
+ filePath,
244
+ target,
245
+ removeBom: input.removeBom ?? true,
246
+ cwd: context.cwd
247
+ });
248
+ if (conversion.exitCode !== 0) {
249
+ return {
250
+ ...envelopeFromRun({
251
+ run: conversion,
252
+ ok: false,
253
+ note: noteFromRun(conversion)
254
+ }),
255
+ target,
256
+ eol_style: eolStyle,
257
+ converter,
258
+ before
259
+ };
260
+ }
261
+
262
+ const after = await sniffEol(filePath);
263
+ const diff = await svnDiff({ cwd: context.cwd, paths: [filePath], ignoreEol: true, lineLimit: defaultDiffLineLimit() });
264
+ const pureEolChurn = diff.ok && diff.diff_excerpt.trim() === "";
265
+
266
+ return {
267
+ ...envelopeFromRun({
268
+ run: conversion,
269
+ ok: true,
270
+ note: pureEolChurn ? "pure EOL churn verified" : "EOL fixed; content diff remains"
271
+ }),
272
+ before,
273
+ after,
274
+ target,
275
+ eol_style: eolStyle,
276
+ converter,
277
+ verification_command: diff.command,
278
+ diff_ignored_eol: true,
279
+ pure_eol_churn: pureEolChurn
280
+ };
281
+ }
282
+
283
+ function pathIsFile(filePath: string): boolean {
284
+ try {
285
+ return fs.statSync(filePath).isFile();
286
+ } catch {
287
+ return false;
288
+ }
289
+ }