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.
- package/.editorconfig +12 -0
- package/.gitattributes +7 -0
- package/.github/workflows/ci.yml +39 -0
- package/.svn-mcp-policy.json +9 -0
- package/CHANGELOG.md +248 -0
- package/CONTRIBUTING.md +49 -0
- package/LICENSE +201 -0
- package/README.md +143 -0
- package/SECURITY.md +21 -0
- package/THIRD_PARTY_CHECKSUMS.txt +52 -0
- package/THIRD_PARTY_NOTICES.md +46 -0
- package/bin/SlikSvn-DB44-20-x64.dll +0 -0
- package/bin/SlikSvn-libapr-1.dll +0 -0
- package/bin/SlikSvn-libaprutil-1.dll +0 -0
- package/bin/SlikSvn-libcrypto-3-x64.dll +0 -0
- package/bin/SlikSvn-libintl.dll +0 -0
- package/bin/SlikSvn-libsasl21.dll +0 -0
- package/bin/SlikSvn-libssl-3-x64.dll +0 -0
- package/bin/SlikSvn-libsvn_client-1.dll +0 -0
- package/bin/SlikSvn-libsvn_delta-1.dll +0 -0
- package/bin/SlikSvn-libsvn_diff-1.dll +0 -0
- package/bin/SlikSvn-libsvn_fs-1.dll +0 -0
- package/bin/SlikSvn-libsvn_fs_base-1.dll +0 -0
- package/bin/SlikSvn-libsvn_fs_fs-1.dll +0 -0
- package/bin/SlikSvn-libsvn_fs_util-1.dll +0 -0
- package/bin/SlikSvn-libsvn_fs_x-1.dll +0 -0
- package/bin/SlikSvn-libsvn_ra-1.dll +0 -0
- package/bin/SlikSvn-libsvn_repos-1.dll +0 -0
- package/bin/SlikSvn-libsvn_subr-1.dll +0 -0
- package/bin/SlikSvn-libsvn_wc-1.dll +0 -0
- package/bin/System64/concrt140.dll +0 -0
- package/bin/System64/msvcp140.dll +0 -0
- package/bin/System64/msvcp140_1.dll +0 -0
- package/bin/System64/msvcp140_2.dll +0 -0
- package/bin/System64/msvcp140_atomic_wait.dll +0 -0
- package/bin/System64/msvcp140_codecvt_ids.dll +0 -0
- package/bin/System64/vccorlib140.dll +0 -0
- package/bin/System64/vcruntime140.dll +0 -0
- package/bin/System64/vcruntime140_1.dll +0 -0
- package/bin/dos2unix.exe +0 -0
- package/bin/engines/capi.dll +0 -0
- package/bin/libsvnjavahl-1.dll +0 -0
- package/bin/mac2unix.exe +0 -0
- package/bin/svn-populate-node-origins-index.exe +0 -0
- package/bin/svn.exe +0 -0
- package/bin/svnadmin.exe +0 -0
- package/bin/svnauthz-validate.exe +0 -0
- package/bin/svnauthz.exe +0 -0
- package/bin/svnbench.exe +0 -0
- package/bin/svndumpfilter.exe +0 -0
- package/bin/svnfsfs.exe +0 -0
- package/bin/svnlook.exe +0 -0
- package/bin/svnmucc.exe +0 -0
- package/bin/svnrdump.exe +0 -0
- package/bin/svnserve.exe +0 -0
- package/bin/svnsync.exe +0 -0
- package/bin/svnversion.exe +0 -0
- package/bin/unix2dos.exe +0 -0
- package/bin/unix2mac.exe +0 -0
- package/dist/envelope.d.ts +39 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +137 -0
- package/dist/envelope.js.map +1 -0
- package/dist/eol.d.ts +15 -0
- package/dist/eol.d.ts.map +1 -0
- package/dist/eol.js +119 -0
- package/dist/eol.js.map +1 -0
- package/dist/guards.d.ts +26 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +306 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +221 -0
- package/dist/index.js.map +1 -0
- package/dist/parse/commitText.d.ts +2 -0
- package/dist/parse/commitText.d.ts.map +1 -0
- package/dist/parse/commitText.js +9 -0
- package/dist/parse/commitText.js.map +1 -0
- package/dist/parse/diffText.d.ts +7 -0
- package/dist/parse/diffText.d.ts.map +1 -0
- package/dist/parse/diffText.js +55 -0
- package/dist/parse/diffText.js.map +1 -0
- package/dist/parse/infoXml.d.ts +3 -0
- package/dist/parse/infoXml.d.ts.map +1 -0
- package/dist/parse/infoXml.js +35 -0
- package/dist/parse/infoXml.js.map +1 -0
- package/dist/parse/logXml.d.ts +10 -0
- package/dist/parse/logXml.d.ts.map +1 -0
- package/dist/parse/logXml.js +39 -0
- package/dist/parse/logXml.js.map +1 -0
- package/dist/parse/statusXml.d.ts +6 -0
- package/dist/parse/statusXml.d.ts.map +1 -0
- package/dist/parse/statusXml.js +64 -0
- package/dist/parse/statusXml.js.map +1 -0
- package/dist/parse/updateText.d.ts +6 -0
- package/dist/parse/updateText.d.ts.map +1 -0
- package/dist/parse/updateText.js +67 -0
- package/dist/parse/updateText.js.map +1 -0
- package/dist/runner.d.ts +41 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +270 -0
- package/dist/runner.js.map +1 -0
- package/dist/tools/composite.d.ts +15 -0
- package/dist/tools/composite.d.ts.map +1 -0
- package/dist/tools/composite.js +238 -0
- package/dist/tools/composite.js.map +1 -0
- package/dist/tools/diagnose.d.ts +6 -0
- package/dist/tools/diagnose.d.ts.map +1 -0
- package/dist/tools/diagnose.js +131 -0
- package/dist/tools/diagnose.js.map +1 -0
- package/dist/tools/mutating.d.ts +80 -0
- package/dist/tools/mutating.d.ts.map +1 -0
- package/dist/tools/mutating.js +558 -0
- package/dist/tools/mutating.js.map +1 -0
- package/dist/tools/readonly.d.ts +52 -0
- package/dist/tools/readonly.d.ts.map +1 -0
- package/dist/tools/readonly.js +638 -0
- package/dist/tools/readonly.js.map +1 -0
- package/dist/tools/selfcheck.d.ts +5 -0
- package/dist/tools/selfcheck.d.ts.map +1 -0
- package/dist/tools/selfcheck.js +91 -0
- package/dist/tools/selfcheck.js.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/README.md +11 -0
- package/docs/SPEC.md +964 -0
- package/docs/decisions/ADR-001-use-spec-v1-architecture.md +25 -0
- package/docs/decisions/ADR-002-use-capability-based-generic-svn-policy.md +31 -0
- package/docs/decisions/ADR-003-use-plug-and-play-global-registration.md +39 -0
- package/docs/decisions/ADR-004-borrow-diagnostics-not-external-mcp-surface.md +39 -0
- package/docs/svnrules.md +84 -0
- package/docs/toolrules.md +34 -0
- package/jest.config.cjs +14 -0
- package/package.json +51 -0
- package/scripts/clean.mjs +27 -0
- package/scripts/prepare-release.mjs +84 -0
- package/src/envelope.ts +182 -0
- package/src/eol.ts +137 -0
- package/src/guards.ts +362 -0
- package/src/index.ts +364 -0
- package/src/parse/commitText.ts +9 -0
- package/src/parse/diffText.ts +66 -0
- package/src/parse/infoXml.ts +51 -0
- package/src/parse/logXml.ts +64 -0
- package/src/parse/statusXml.ts +87 -0
- package/src/parse/updateText.ts +76 -0
- package/src/runner.ts +344 -0
- package/src/tools/composite.ts +289 -0
- package/src/tools/diagnose.ts +150 -0
- package/src/tools/mutating.ts +694 -0
- package/src/tools/readonly.ts +786 -0
- package/src/tools/selfcheck.ts +97 -0
- package/src/types.ts +76 -0
- package/tests/README.md +9 -0
- package/tests/diff-redaction.test.ts +146 -0
- package/tests/entrypoint.test.ts +49 -0
- package/tests/guards-eol.test.ts +170 -0
- package/tests/integration-svn.test.ts +709 -0
- package/tests/parsers.test.ts +119 -0
- package/tests/runner.test.ts +159 -0
- package/tests/selfcheck.test.ts +22 -0
- 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
|
+
}
|