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,709 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { svnAdminExecutable, svnExecutable } from "../src/runner.js";
|
|
8
|
+
import { eolFixVerified, svnPrecommit } from "../src/tools/composite.js";
|
|
9
|
+
import { svnDiagnose } from "../src/tools/diagnose.js";
|
|
10
|
+
import { svnAdd, svnCommit, svnCopy, svnExport, svnImport, svnMove, svnPropset, svnRename, svnRevert, svnUpdate } from "../src/tools/mutating.js";
|
|
11
|
+
import { eolCheck, svnDiff, svnInfo, svnLog, svnPropget, svnStatus } from "../src/tools/readonly.js";
|
|
12
|
+
|
|
13
|
+
jest.setTimeout(30000);
|
|
14
|
+
|
|
15
|
+
describe("SVN tool integration against a temp repository", () => {
|
|
16
|
+
it("detects LF EOL churn, fixes it, and commits via a -F message flow", async () => {
|
|
17
|
+
const fixture = createTempWorkingCopy();
|
|
18
|
+
try {
|
|
19
|
+
const file = path.join(fixture.wc, "app.txt");
|
|
20
|
+
fs.writeFileSync(file, "one\r\ntwo\r\n", "utf8");
|
|
21
|
+
|
|
22
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["app.txt"] })).ok).toBe(true);
|
|
23
|
+
expect((await svnCommit({ cwd: fixture.wc, paths: ["app.txt"], message: commitMessage("Initial import") })).ok).toBe(true);
|
|
24
|
+
execFileSync(svnExecutable(), ["propset", "svn:eol-style", "native", file], { cwd: fixture.wc });
|
|
25
|
+
execFileSync(svnExecutable(), ["commit", "-m", "set prop", file], { cwd: fixture.wc });
|
|
26
|
+
|
|
27
|
+
fs.writeFileSync(file, "one\nTWO\n", "utf8");
|
|
28
|
+
const damaged = await svnPrecommit({ cwd: fixture.wc, paths: ["app.txt"] });
|
|
29
|
+
expect(damaged.verdict).toBe("EOL_FIX_NEEDED");
|
|
30
|
+
|
|
31
|
+
const diff = await svnDiff({ cwd: fixture.wc, paths: ["app.txt"] });
|
|
32
|
+
expectSvnArgs(diff.command, "diff --internal-diff -x --ignore-eol-style");
|
|
33
|
+
|
|
34
|
+
const fixed = await eolFixVerified({ cwd: fixture.wc, path: "app.txt" });
|
|
35
|
+
expect(fixed.ok).toBe(true);
|
|
36
|
+
expect(fixed.command.toLowerCase()).toContain("unix2dos");
|
|
37
|
+
expect(fixed.command.toLowerCase()).not.toContain("powershell");
|
|
38
|
+
expect(fixed.converter).toBe("unix2dos");
|
|
39
|
+
expectSvnArgs(String(fixed.verification_command), "diff --internal-diff -x --ignore-eol-style");
|
|
40
|
+
expect(fixed.pure_eol_churn).toBe(false);
|
|
41
|
+
|
|
42
|
+
const ready = await svnPrecommit({ cwd: fixture.wc, paths: ["app.txt"] });
|
|
43
|
+
expect(ready.verdict).toBe("READY");
|
|
44
|
+
|
|
45
|
+
const committed = await svnCommit({
|
|
46
|
+
cwd: fixture.wc,
|
|
47
|
+
paths: ["app.txt"],
|
|
48
|
+
message: commitMessage("Update app text")
|
|
49
|
+
});
|
|
50
|
+
expect(committed.ok).toBe(true);
|
|
51
|
+
expect(typeof committed.revision).toBe("number");
|
|
52
|
+
expect(committed.post_status_clean).toBe(true);
|
|
53
|
+
expect((await svnStatus({ cwd: fixture.wc })).changed_paths).toEqual([]);
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reports mixed revision ranges and remote HEAD details without treating them as dirty state", async () => {
|
|
60
|
+
const fixture = createTempWorkingCopy();
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(path.join(fixture.wc, "info.txt"), "one\r\n", "utf8");
|
|
63
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["info.txt"] })).ok).toBe(true);
|
|
64
|
+
const committed = await svnCommit({
|
|
65
|
+
cwd: fixture.wc,
|
|
66
|
+
paths: ["info.txt"],
|
|
67
|
+
message: commitMessage("Add info fixture")
|
|
68
|
+
});
|
|
69
|
+
expect(committed.ok).toBe(true);
|
|
70
|
+
|
|
71
|
+
const info = await svnInfo({ cwd: fixture.wc });
|
|
72
|
+
expect(info.ok).toBe(true);
|
|
73
|
+
expect(info.mixed_revision).toBe(true);
|
|
74
|
+
expect(info.local_modifications).toBe(false);
|
|
75
|
+
expect(info.revision_range).toEqual({ min: 0, max: committed.revision });
|
|
76
|
+
expect(info.remote_head_revision).toBe(committed.revision);
|
|
77
|
+
expect(info.note).toContain("mixed revision working copy");
|
|
78
|
+
expect(info.note).not.toContain("local modifications present");
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("diagnoses local and remote SVN health for a working copy", async () => {
|
|
85
|
+
const fixture = createTempWorkingCopy();
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(path.join(fixture.wc, "diag.txt"), "one\r\n", "utf8");
|
|
88
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["diag.txt"] })).ok).toBe(true);
|
|
89
|
+
expect((await svnCommit({
|
|
90
|
+
cwd: fixture.wc,
|
|
91
|
+
paths: ["diag.txt"],
|
|
92
|
+
message: commitMessage("Add diagnose fixture")
|
|
93
|
+
})).ok).toBe(true);
|
|
94
|
+
|
|
95
|
+
const diagnosed = await svnDiagnose({ cwd: fixture.wc });
|
|
96
|
+
const checks = new Map((diagnosed.checks as Array<{ name: string; ok: boolean }>).map((check) => [check.name, check.ok]));
|
|
97
|
+
|
|
98
|
+
expect(diagnosed.ok).toBe(true);
|
|
99
|
+
expect(diagnosed.health).toBe("healthy");
|
|
100
|
+
expect(diagnosed.working_copy_valid).toBe(true);
|
|
101
|
+
expect(diagnosed.remote_accessible).toBe(true);
|
|
102
|
+
expect(checks.get("local_status")).toBe(true);
|
|
103
|
+
expect(checks.get("remote_status")).toBe(true);
|
|
104
|
+
expect(checks.get("remote_info_head")).toBe(true);
|
|
105
|
+
expect(checks.get("log_latest")).toBe(true);
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns structured diagnosis for non-working-copy paths", async () => {
|
|
112
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-not-wc-"));
|
|
113
|
+
try {
|
|
114
|
+
const diagnosed = await svnDiagnose({ cwd: root });
|
|
115
|
+
|
|
116
|
+
expect(diagnosed.ok).toBe(false);
|
|
117
|
+
expect(diagnosed.health).toBe("error");
|
|
118
|
+
expect(diagnosed.working_copy_valid).toBe(false);
|
|
119
|
+
expect(diagnosed.suggestions).toEqual(expect.arrayContaining([
|
|
120
|
+
expect.stringContaining("SVN working copy")
|
|
121
|
+
]));
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("requires cwd or an absolute path hint for ambiguous read-only calls", async () => {
|
|
128
|
+
const status = await svnStatus({});
|
|
129
|
+
|
|
130
|
+
expect(status.ok).toBe(false);
|
|
131
|
+
expect(status.note).toContain("cwd or absolute path required");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("enforces READONLY and update explicit-target guards", async () => {
|
|
135
|
+
const fixture = createTempWorkingCopy();
|
|
136
|
+
const oldReadonly = process.env.SVN_AGENT_READONLY;
|
|
137
|
+
try {
|
|
138
|
+
process.env.SVN_AGENT_READONLY = "1";
|
|
139
|
+
const refused = await svnCommit({
|
|
140
|
+
cwd: fixture.wc,
|
|
141
|
+
paths: ["missing.txt"],
|
|
142
|
+
message: commitMessage("Should refuse")
|
|
143
|
+
});
|
|
144
|
+
expect(refused.ok).toBe(false);
|
|
145
|
+
expect(refused.note).toBe("READONLY instance");
|
|
146
|
+
expect((await svnMove({ cwd: fixture.wc, src: "missing.txt", dest: "other.txt" })).note).toBe("READONLY instance");
|
|
147
|
+
expect((await svnCopy({ cwd: fixture.wc, src: "missing.txt", dest: "other.txt" })).note).toBe("READONLY instance");
|
|
148
|
+
} finally {
|
|
149
|
+
if (oldReadonly === undefined) {
|
|
150
|
+
delete process.env.SVN_AGENT_READONLY;
|
|
151
|
+
} else {
|
|
152
|
+
process.env.SVN_AGENT_READONLY = oldReadonly;
|
|
153
|
+
}
|
|
154
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const updateRefusal = await svnUpdate({ cwd: process.cwd() });
|
|
158
|
+
expect(updateRefusal.ok).toBe(false);
|
|
159
|
+
expect(updateRefusal.note).toContain("explicit paths required");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("adds missing parent directories for an explicit new file path without adding siblings", async () => {
|
|
163
|
+
const fixture = createTempWorkingCopy();
|
|
164
|
+
try {
|
|
165
|
+
const nested = path.join(fixture.wc, "src", "feature");
|
|
166
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
167
|
+
fs.writeFileSync(path.join(nested, "app.ts"), "export const value = 1;\r\n", "utf8");
|
|
168
|
+
fs.writeFileSync(path.join(nested, "scratch.tmp"), "do not add\r\n", "utf8");
|
|
169
|
+
|
|
170
|
+
const added = await svnAdd({ cwd: fixture.wc, paths: ["src/feature/app.ts"] });
|
|
171
|
+
expect(added.ok).toBe(true);
|
|
172
|
+
|
|
173
|
+
const status = await svnStatus({ cwd: fixture.wc, paths: ["src"] });
|
|
174
|
+
const byPath = new Map(status.changed_paths.map((entry) => [normalizeStatusPath(entry.path, fixture.wc), entry.status]));
|
|
175
|
+
expect(byPath.get("src")).toBe("A");
|
|
176
|
+
expect(byPath.get("src/feature")).toBe("A");
|
|
177
|
+
expect(byPath.get("src/feature/app.ts")).toBe("A");
|
|
178
|
+
expect(byPath.get("src/feature/scratch.tmp")).toBe("?");
|
|
179
|
+
|
|
180
|
+
const committed = await svnCommit({
|
|
181
|
+
cwd: fixture.wc,
|
|
182
|
+
paths: ["src/feature/app.ts"],
|
|
183
|
+
message: commitMessage("Add nested app")
|
|
184
|
+
});
|
|
185
|
+
expect(committed.ok).toBe(true);
|
|
186
|
+
expect(committed.post_status_clean).toBe(true);
|
|
187
|
+
expect((await svnStatus({ cwd: fixture.wc, paths: ["src/feature/app.ts"] })).changed_paths).toEqual([]);
|
|
188
|
+
expect((await svnStatus({ cwd: fixture.wc, paths: ["src/feature/scratch.tmp"] })).changed_paths[0]?.status).toBe("?");
|
|
189
|
+
} finally {
|
|
190
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("blocks recursive adds when a descendant matches never-commit guards", async () => {
|
|
195
|
+
const fixture = createTempWorkingCopy();
|
|
196
|
+
try {
|
|
197
|
+
const buildOutput = path.join(fixture.wc, "src", "App", "bin", "Debug");
|
|
198
|
+
fs.mkdirSync(buildOutput, { recursive: true });
|
|
199
|
+
fs.writeFileSync(path.join(buildOutput, "app.dll"), "not really binary\r\n", "utf8");
|
|
200
|
+
|
|
201
|
+
const added = await svnAdd({ cwd: fixture.wc, paths: ["src"], allowRecursive: true });
|
|
202
|
+
|
|
203
|
+
expect(added.ok).toBe(false);
|
|
204
|
+
expect(added.note).toContain("never-commit path matches **/bin/**");
|
|
205
|
+
expect(added.note).toContain("src/App/bin");
|
|
206
|
+
} finally {
|
|
207
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("blocks svn import when the source tree contains never-commit descendants", async () => {
|
|
212
|
+
const fixture = createTempWorkingCopy();
|
|
213
|
+
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-import-src-"));
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.join(srcRoot, "node_modules", "pkg"), { recursive: true });
|
|
216
|
+
fs.writeFileSync(path.join(srcRoot, "node_modules", "pkg", "index.js"), "module.exports = 1;\r\n", "utf8");
|
|
217
|
+
|
|
218
|
+
const imported = await svnImport({
|
|
219
|
+
cwd: fixture.wc,
|
|
220
|
+
src: srcRoot,
|
|
221
|
+
url: `${pathToFileURL(fixture.repo).href}/imported`,
|
|
222
|
+
message: commitMessage("Import guarded tree")
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(imported.ok).toBe(false);
|
|
226
|
+
expect(imported.note).toContain("never-commit path");
|
|
227
|
+
expect(imported.note).toContain("node_modules");
|
|
228
|
+
} finally {
|
|
229
|
+
fs.rmSync(srcRoot, { recursive: true, force: true });
|
|
230
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("infers the working copy from absolute paths without cwd across multiple repositories", async () => {
|
|
235
|
+
const first = createTempWorkingCopy();
|
|
236
|
+
const second = createTempWorkingCopy();
|
|
237
|
+
try {
|
|
238
|
+
const firstFile = path.join(first.wc, "first.txt");
|
|
239
|
+
const secondFile = path.join(second.wc, "second.txt");
|
|
240
|
+
fs.writeFileSync(firstFile, "one\r\n", "utf8");
|
|
241
|
+
fs.writeFileSync(secondFile, "two\r\n", "utf8");
|
|
242
|
+
|
|
243
|
+
const firstAdded = await svnAdd({ paths: [firstFile] });
|
|
244
|
+
const secondAdded = await svnAdd({ paths: [secondFile] });
|
|
245
|
+
|
|
246
|
+
expect(firstAdded.ok).toBe(true);
|
|
247
|
+
expect(secondAdded.ok).toBe(true);
|
|
248
|
+
|
|
249
|
+
const firstStatus = await svnStatus({ paths: [firstFile] });
|
|
250
|
+
const secondStatus = await svnStatus({ paths: [secondFile] });
|
|
251
|
+
|
|
252
|
+
expect(statusByPath(firstStatus.changed_paths, first.wc).get("first.txt")).toBe("A");
|
|
253
|
+
expect(statusByPath(secondStatus.changed_paths, second.wc).get("second.txt")).toBe("A");
|
|
254
|
+
|
|
255
|
+
const firstCommitted = await svnCommit({
|
|
256
|
+
paths: [firstFile],
|
|
257
|
+
message: commitMessage("Commit first absolute path")
|
|
258
|
+
});
|
|
259
|
+
const secondCommitted = await svnCommit({
|
|
260
|
+
paths: [secondFile],
|
|
261
|
+
message: commitMessage("Commit second absolute path")
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(firstCommitted.ok).toBe(true);
|
|
265
|
+
expect(secondCommitted.ok).toBe(true);
|
|
266
|
+
expect((await svnStatus({ paths: [firstFile] })).changed_paths).toEqual([]);
|
|
267
|
+
expect((await svnStatus({ paths: [secondFile] })).changed_paths).toEqual([]);
|
|
268
|
+
} finally {
|
|
269
|
+
fs.rmSync(first.root, { recursive: true, force: true });
|
|
270
|
+
fs.rmSync(second.root, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("queries repository URLs for logs so mixed-revision working-copy roots still show latest commits", async () => {
|
|
275
|
+
const fixture = createTempWorkingCopy();
|
|
276
|
+
try {
|
|
277
|
+
fs.writeFileSync(path.join(fixture.wc, "root-log.txt"), "one\r\n", "utf8");
|
|
278
|
+
|
|
279
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["root-log.txt"] })).ok).toBe(true);
|
|
280
|
+
const committed = await svnCommit({
|
|
281
|
+
cwd: fixture.wc,
|
|
282
|
+
paths: ["root-log.txt"],
|
|
283
|
+
message: commitMessage("Add root log fixture")
|
|
284
|
+
});
|
|
285
|
+
expect(committed.ok).toBe(true);
|
|
286
|
+
|
|
287
|
+
const rawWorkingCopyLog = execFileSync(svnExecutable(), ["log", "-l", "1", fixture.wc], {
|
|
288
|
+
cwd: fixture.wc,
|
|
289
|
+
encoding: "utf8"
|
|
290
|
+
});
|
|
291
|
+
expect(rawWorkingCopyLog).not.toContain(`r${committed.revision}`);
|
|
292
|
+
|
|
293
|
+
const log = await svnLog({ cwd: fixture.wc, limit: 1 });
|
|
294
|
+
expect(log.ok).toBe(true);
|
|
295
|
+
expect(log.revision).toBe(committed.revision);
|
|
296
|
+
expect(log.target_mode).toBe("repository-url");
|
|
297
|
+
expect(log.note).toContain("repository URL at HEAD");
|
|
298
|
+
} finally {
|
|
299
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("can hide common local runtime noise from status while keeping actionable unversioned files", async () => {
|
|
304
|
+
const fixture = createTempWorkingCopy();
|
|
305
|
+
try {
|
|
306
|
+
fs.mkdirSync(path.join(fixture.wc, "node_modules", "pkg"), { recursive: true });
|
|
307
|
+
fs.mkdirSync(path.join(fixture.wc, "dist"), { recursive: true });
|
|
308
|
+
fs.writeFileSync(path.join(fixture.wc, "node_modules", "pkg", "index.js"), "module.exports = 1;\r\n", "utf8");
|
|
309
|
+
fs.writeFileSync(path.join(fixture.wc, "dist", "bundle.js"), "bundle\r\n", "utf8");
|
|
310
|
+
fs.writeFileSync(path.join(fixture.wc, "todo.txt"), "todo\r\n", "utf8");
|
|
311
|
+
|
|
312
|
+
const noisy = await svnStatus({ cwd: fixture.wc });
|
|
313
|
+
expect(statusByPath(noisy.changed_paths, fixture.wc).get("node_modules")).toBe("?");
|
|
314
|
+
expect(statusByPath(noisy.changed_paths, fixture.wc).get("dist")).toBe("?");
|
|
315
|
+
expect(statusByPath(noisy.changed_paths, fixture.wc).get("todo.txt")).toBe("?");
|
|
316
|
+
|
|
317
|
+
const filtered = await svnStatus({ cwd: fixture.wc, hideNoise: true });
|
|
318
|
+
const byPath = statusByPath(filtered.changed_paths, fixture.wc);
|
|
319
|
+
expect(byPath.get("node_modules")).toBeUndefined();
|
|
320
|
+
expect(byPath.get("dist")).toBeUndefined();
|
|
321
|
+
expect(byPath.get("todo.txt")).toBe("?");
|
|
322
|
+
expect(filtered.filtered_paths).toEqual(expect.arrayContaining(["node_modules", "dist"]));
|
|
323
|
+
} finally {
|
|
324
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("surfaces property-only status changes and property conflicts", async () => {
|
|
329
|
+
const fixture = createTempWorkingCopy();
|
|
330
|
+
try {
|
|
331
|
+
const file = path.join(fixture.wc, "props.txt");
|
|
332
|
+
fs.writeFileSync(file, "one\r\n", "utf8");
|
|
333
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["props.txt"] })).ok).toBe(true);
|
|
334
|
+
expect((await svnCommit({ cwd: fixture.wc, paths: ["props.txt"], message: commitMessage("Add props fixture") })).ok).toBe(true);
|
|
335
|
+
|
|
336
|
+
execFileSync(svnExecutable(), ["propset", "custom:flag", "yes", file], { cwd: fixture.wc });
|
|
337
|
+
const status = await svnStatus({ cwd: fixture.wc, paths: ["props.txt"] });
|
|
338
|
+
|
|
339
|
+
expect(statusByPath(status.changed_paths, fixture.wc).get("props.txt")).toBe("_M");
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("gets and sets explicit working-copy properties without raw svn commands", async () => {
|
|
346
|
+
const fixture = createTempWorkingCopy();
|
|
347
|
+
try {
|
|
348
|
+
fs.writeFileSync(path.join(fixture.wc, "props.txt"), "one\r\n", "utf8");
|
|
349
|
+
fs.writeFileSync(path.join(fixture.wc, "props-missing.txt"), "two\r\n", "utf8");
|
|
350
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["props.txt", "props-missing.txt"] })).ok).toBe(true);
|
|
351
|
+
expect((await svnCommit({ cwd: fixture.wc, paths: ["props.txt", "props-missing.txt"], message: commitMessage("Add prop targets") })).ok).toBe(true);
|
|
352
|
+
|
|
353
|
+
const missing = await svnPropget({ cwd: fixture.wc, paths: ["props.txt"], name: "custom:review-note" });
|
|
354
|
+
expect(missing.ok).toBe(true);
|
|
355
|
+
expect(missing.properties).toEqual([]);
|
|
356
|
+
expect(missing.missing_paths).toEqual(["props.txt"]);
|
|
357
|
+
|
|
358
|
+
const set = await svnPropset({
|
|
359
|
+
cwd: fixture.wc,
|
|
360
|
+
paths: ["props.txt"],
|
|
361
|
+
name: "custom:review-note",
|
|
362
|
+
value: "checked by MCP"
|
|
363
|
+
});
|
|
364
|
+
expect(set.ok).toBe(true);
|
|
365
|
+
|
|
366
|
+
const got = await svnPropget({ cwd: fixture.wc, paths: ["props.txt"], name: "custom:review-note" });
|
|
367
|
+
expect(got.ok).toBe(true);
|
|
368
|
+
expect(got.properties).toEqual([
|
|
369
|
+
{ path: "props.txt", name: "custom:review-note", value: "checked by MCP" }
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
const mixed = await svnPropget({ cwd: fixture.wc, paths: ["props.txt", "props-missing.txt"], name: "custom:review-note" });
|
|
373
|
+
expect(mixed.ok).toBe(true);
|
|
374
|
+
expect(mixed.note).toBe("property not set on some paths");
|
|
375
|
+
expect(mixed.properties).toEqual([
|
|
376
|
+
{ path: "props.txt", name: "custom:review-note", value: "checked by MCP" }
|
|
377
|
+
]);
|
|
378
|
+
expect(mixed.missing_paths).toEqual(["props-missing.txt"]);
|
|
379
|
+
expect(statusByPath((await svnStatus({ cwd: fixture.wc, paths: ["props.txt"] })).changed_paths, fixture.wc).get("props.txt")).toBe("_M");
|
|
380
|
+
} finally {
|
|
381
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("refuses generic propset in readonly mode while allowing propget", async () => {
|
|
386
|
+
const fixture = createTempWorkingCopy();
|
|
387
|
+
const oldReadonly = process.env.SVN_AGENT_READONLY;
|
|
388
|
+
try {
|
|
389
|
+
fs.writeFileSync(path.join(fixture.wc, "readonly-prop.txt"), "one\r\n", "utf8");
|
|
390
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["readonly-prop.txt"] })).ok).toBe(true);
|
|
391
|
+
expect((await svnCommit({ cwd: fixture.wc, paths: ["readonly-prop.txt"], message: commitMessage("Add readonly prop target") })).ok).toBe(true);
|
|
392
|
+
expect((await svnPropset({
|
|
393
|
+
cwd: fixture.wc,
|
|
394
|
+
paths: ["readonly-prop.txt"],
|
|
395
|
+
name: "custom:readonly",
|
|
396
|
+
value: "yes"
|
|
397
|
+
})).ok).toBe(true);
|
|
398
|
+
|
|
399
|
+
process.env.SVN_AGENT_READONLY = "1";
|
|
400
|
+
const refused = await svnPropset({
|
|
401
|
+
cwd: fixture.wc,
|
|
402
|
+
paths: ["readonly-prop.txt"],
|
|
403
|
+
name: "custom:readonly",
|
|
404
|
+
value: "no"
|
|
405
|
+
});
|
|
406
|
+
const read = await svnPropget({ cwd: fixture.wc, paths: ["readonly-prop.txt"], name: "custom:readonly" });
|
|
407
|
+
|
|
408
|
+
expect(refused.ok).toBe(false);
|
|
409
|
+
expect(refused.note).toBe("READONLY instance");
|
|
410
|
+
expect(read.ok).toBe(true);
|
|
411
|
+
} finally {
|
|
412
|
+
if (oldReadonly === undefined) {
|
|
413
|
+
delete process.env.SVN_AGENT_READONLY;
|
|
414
|
+
} else {
|
|
415
|
+
process.env.SVN_AGENT_READONLY = oldReadonly;
|
|
416
|
+
}
|
|
417
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("rejects malformed export revisions before invoking SVN", async () => {
|
|
422
|
+
const fixture = createTempWorkingCopy();
|
|
423
|
+
try {
|
|
424
|
+
const exported = await svnExport({
|
|
425
|
+
cwd: fixture.wc,
|
|
426
|
+
src: pathToFileURL(fixture.repo).href,
|
|
427
|
+
dest: path.join(fixture.root, "exported"),
|
|
428
|
+
revision: "not a revision"
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(exported.ok).toBe(false);
|
|
432
|
+
expect(exported.note).toContain("invalid revision");
|
|
433
|
+
} finally {
|
|
434
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("returns EOL diagnostics when svn diff fails on inconsistent line endings", async () => {
|
|
439
|
+
const fixture = createTempWorkingCopy();
|
|
440
|
+
try {
|
|
441
|
+
const file = path.join(fixture.wc, "mixed-eol.txt");
|
|
442
|
+
fs.writeFileSync(file, "one\r\ntwo\r\n", "utf8");
|
|
443
|
+
|
|
444
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["mixed-eol.txt"] })).ok).toBe(true);
|
|
445
|
+
expect((await svnCommit({
|
|
446
|
+
cwd: fixture.wc,
|
|
447
|
+
paths: ["mixed-eol.txt"],
|
|
448
|
+
message: commitMessage("Add mixed EOL fixture")
|
|
449
|
+
})).ok).toBe(true);
|
|
450
|
+
execFileSync(svnExecutable(), ["propset", "svn:eol-style", "native", file], { cwd: fixture.wc });
|
|
451
|
+
execFileSync(svnExecutable(), ["commit", "-m", "set native eol", file], { cwd: fixture.wc });
|
|
452
|
+
|
|
453
|
+
fs.writeFileSync(file, "one\r\ntwo\nthree\r\n", "utf8");
|
|
454
|
+
const diff = await svnDiff({ cwd: fixture.wc, paths: ["mixed-eol.txt"] });
|
|
455
|
+
|
|
456
|
+
expect(diff.ok).toBe(false);
|
|
457
|
+
expect(diff.note).toContain("inconsistent line endings");
|
|
458
|
+
expect(diff.recovery_tool).toBe("eol_fix_verified");
|
|
459
|
+
expect(diff.eol_files[0]?.kind).toBe("mixed");
|
|
460
|
+
expect(diff.eol_files[0]?.mismatch).toBe(true);
|
|
461
|
+
|
|
462
|
+
const precommit = await svnPrecommit({ cwd: fixture.wc, paths: ["mixed-eol.txt"] });
|
|
463
|
+
expect(precommit.verdict).toBe("EOL_FIX_NEEDED");
|
|
464
|
+
expect(precommit.note).toContain("svn diff failed");
|
|
465
|
+
} finally {
|
|
466
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("derives the EOL converter from svn:eol-style when no target is supplied", async () => {
|
|
471
|
+
const fixture = createTempWorkingCopy();
|
|
472
|
+
try {
|
|
473
|
+
const file = path.join(fixture.wc, "lf.txt");
|
|
474
|
+
fs.writeFileSync(file, "one\r\ntwo\r\n", "utf8");
|
|
475
|
+
|
|
476
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["lf.txt"] })).ok).toBe(true);
|
|
477
|
+
execFileSync(svnExecutable(), ["propset", "svn:eol-style", "LF", file], { cwd: fixture.wc });
|
|
478
|
+
|
|
479
|
+
const check = await eolCheck({ cwd: fixture.wc, paths: ["lf.txt"] });
|
|
480
|
+
expect(check.files[0]?.mismatch).toBe(true);
|
|
481
|
+
|
|
482
|
+
const fixed = await eolFixVerified({ cwd: fixture.wc, path: "lf.txt" });
|
|
483
|
+
const after = fixed.after as { kind?: string } | undefined;
|
|
484
|
+
|
|
485
|
+
expect(fixed.ok).toBe(true);
|
|
486
|
+
expect(fixed.converter).toBe("dos2unix");
|
|
487
|
+
expect(fixed.target).toBe("lf");
|
|
488
|
+
expect(fixed.command.toLowerCase()).toContain("dos2unix");
|
|
489
|
+
expect(fixed.command.toLowerCase()).not.toContain("powershell");
|
|
490
|
+
expect(after?.kind).toBe("lf");
|
|
491
|
+
} finally {
|
|
492
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("returns a structured EOL repair refusal for missing files", async () => {
|
|
497
|
+
const fixture = createTempWorkingCopy();
|
|
498
|
+
try {
|
|
499
|
+
const fixed = await eolFixVerified({ cwd: fixture.wc, path: "missing.txt" });
|
|
500
|
+
|
|
501
|
+
expect(fixed.ok).toBe(false);
|
|
502
|
+
expect(fixed.command).toBe("eol_fix_verified");
|
|
503
|
+
expect(fixed.note).toContain("path does not exist");
|
|
504
|
+
} finally {
|
|
505
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("moves, renames, and copies working-copy files with parent directories", async () => {
|
|
510
|
+
const fixture = createTempWorkingCopy();
|
|
511
|
+
try {
|
|
512
|
+
const file = path.join(fixture.wc, "source.txt");
|
|
513
|
+
fs.writeFileSync(file, "one\r\n", "utf8");
|
|
514
|
+
|
|
515
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["source.txt"] })).ok).toBe(true);
|
|
516
|
+
expect((await svnCommit({ cwd: fixture.wc, paths: ["source.txt"], message: commitMessage("Add source") })).ok).toBe(true);
|
|
517
|
+
|
|
518
|
+
const moved = await svnMove({ cwd: fixture.wc, src: "source.txt", dest: "moved/source.txt" });
|
|
519
|
+
expect(moved.ok).toBe(true);
|
|
520
|
+
expectSvnArgs(moved.command, "move --parents");
|
|
521
|
+
expect(statusByPath(moved.changed_paths, fixture.wc).get("source.txt")).toBe("D");
|
|
522
|
+
expect(statusByPath(moved.changed_paths, fixture.wc).get("moved/source.txt")).toBe("A");
|
|
523
|
+
expect((await svnCommit({
|
|
524
|
+
cwd: fixture.wc,
|
|
525
|
+
paths: ["source.txt", "moved/source.txt"],
|
|
526
|
+
message: commitMessage("Move source"),
|
|
527
|
+
riskAck: true
|
|
528
|
+
})).ok).toBe(true);
|
|
529
|
+
|
|
530
|
+
const copied = await svnCopy({ cwd: fixture.wc, src: "moved/source.txt", dest: "copies/source-copy.txt" });
|
|
531
|
+
expect(copied.ok).toBe(true);
|
|
532
|
+
expectSvnArgs(copied.command, "copy --parents");
|
|
533
|
+
expect(statusByPath(copied.changed_paths, fixture.wc).get("copies/source-copy.txt")).toBe("A");
|
|
534
|
+
expect((await svnCommit({
|
|
535
|
+
cwd: fixture.wc,
|
|
536
|
+
paths: ["copies/source-copy.txt"],
|
|
537
|
+
message: commitMessage("Copy source")
|
|
538
|
+
})).ok).toBe(true);
|
|
539
|
+
|
|
540
|
+
const renamed = await svnRename({ cwd: fixture.wc, src: "moved/source.txt", dest: "moved/final.txt" });
|
|
541
|
+
expect(renamed.ok).toBe(true);
|
|
542
|
+
expectSvnArgs(renamed.command, "move --parents");
|
|
543
|
+
expect(statusByPath(renamed.changed_paths, fixture.wc).get("moved/source.txt")).toBe("D");
|
|
544
|
+
expect(statusByPath(renamed.changed_paths, fixture.wc).get("moved/final.txt")).toBe("A");
|
|
545
|
+
expect((await svnCommit({
|
|
546
|
+
cwd: fixture.wc,
|
|
547
|
+
paths: ["moved/source.txt", "moved/final.txt"],
|
|
548
|
+
message: commitMessage("Rename source"),
|
|
549
|
+
riskAck: true
|
|
550
|
+
})).ok).toBe(true);
|
|
551
|
+
|
|
552
|
+
const blocked = await svnCopy({ cwd: fixture.wc, src: "moved/final.txt", dest: ".env" });
|
|
553
|
+
expect(blocked.ok).toBe(false);
|
|
554
|
+
expect(blocked.note).toContain("never-commit path");
|
|
555
|
+
} finally {
|
|
556
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("normalizes commit message files before invoking svn", async () => {
|
|
561
|
+
const fixture = createTempWorkingCopy();
|
|
562
|
+
try {
|
|
563
|
+
fs.writeFileSync(path.join(fixture.wc, "message-eol.txt"), "one\r\n", "utf8");
|
|
564
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["message-eol.txt"] })).ok).toBe(true);
|
|
565
|
+
|
|
566
|
+
const committed = await svnCommit({
|
|
567
|
+
cwd: fixture.wc,
|
|
568
|
+
paths: ["message-eol.txt"],
|
|
569
|
+
message: "Add message EOL fixture\r\n\r\n- First line uses CRLF\r\n- Second line uses LF\n- Verified by integration test\r\n"
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
expect(committed.ok).toBe(true);
|
|
573
|
+
expect(committed.note).not.toContain("inconsistent line endings");
|
|
574
|
+
} finally {
|
|
575
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("redacts secrets from diff excerpts returned by diff and precommit", async () => {
|
|
580
|
+
const fixture = createTempWorkingCopy();
|
|
581
|
+
try {
|
|
582
|
+
fs.writeFileSync(path.join(fixture.wc, "url-redaction.txt"), "safe\r\n", "utf8");
|
|
583
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["url-redaction.txt"] })).ok).toBe(true);
|
|
584
|
+
expect((await svnCommit({
|
|
585
|
+
cwd: fixture.wc,
|
|
586
|
+
paths: ["url-redaction.txt"],
|
|
587
|
+
message: commitMessage("Add redaction fixture")
|
|
588
|
+
})).ok).toBe(true);
|
|
589
|
+
|
|
590
|
+
fs.writeFileSync(
|
|
591
|
+
path.join(fixture.wc, "url-redaction.txt"),
|
|
592
|
+
"https://user:secret@example.com/repo?token=abc123&apikey=def456\r\n",
|
|
593
|
+
"utf8"
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const diff = await svnDiff({ cwd: fixture.wc, paths: ["url-redaction.txt"] });
|
|
597
|
+
expect(diff.ok).toBe(true);
|
|
598
|
+
expect(diff.diff_excerpt).toContain("https://***:***@example.com/repo?token=***&apikey=***");
|
|
599
|
+
expect(diff.diff_excerpt).not.toContain("secret");
|
|
600
|
+
expect(diff.diff_excerpt).not.toContain("abc123");
|
|
601
|
+
expect(diff.diff_excerpt).not.toContain("def456");
|
|
602
|
+
|
|
603
|
+
const precommit = await svnPrecommit({ cwd: fixture.wc, paths: ["url-redaction.txt"] });
|
|
604
|
+
expect(precommit.diff_excerpt).toContain("https://***:***@example.com/repo?token=***&apikey=***");
|
|
605
|
+
expect(precommit.diff_excerpt).not.toContain("secret");
|
|
606
|
+
expect(precommit.diff_excerpt).not.toContain("abc123");
|
|
607
|
+
expect(precommit.diff_excerpt).not.toContain("def456");
|
|
608
|
+
} finally {
|
|
609
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("returns envelopes when mutation targets disappear before stat", async () => {
|
|
614
|
+
const fixture = createTempWorkingCopy();
|
|
615
|
+
const originalStatSync = fs.statSync.bind(fs);
|
|
616
|
+
let statSpy: jest.SpiedFunction<typeof fs.statSync> | null = null;
|
|
617
|
+
try {
|
|
618
|
+
const vanishingAdd = path.join(fixture.wc, "vanishing-add.txt");
|
|
619
|
+
const vanishingImport = path.join(fixture.root, "vanishing-import");
|
|
620
|
+
fs.writeFileSync(vanishingAdd, "gone\r\n", "utf8");
|
|
621
|
+
fs.mkdirSync(vanishingImport);
|
|
622
|
+
const failPaths = new Set([
|
|
623
|
+
path.resolve(vanishingAdd).toLowerCase(),
|
|
624
|
+
path.resolve(vanishingImport).toLowerCase()
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
statSpy = jest.spyOn(fs, "statSync");
|
|
628
|
+
(statSpy as unknown as { mockImplementation: (fn: (...args: unknown[]) => unknown) => void }).mockImplementation(
|
|
629
|
+
(target: unknown, options?: unknown) => {
|
|
630
|
+
const targetPath = path.resolve(String(target)).toLowerCase();
|
|
631
|
+
if (failPaths.has(targetPath)) {
|
|
632
|
+
throw Object.assign(new Error("simulated stat race"), { code: "ENOENT" });
|
|
633
|
+
}
|
|
634
|
+
return originalStatSync(target as fs.PathLike, options as fs.StatSyncOptions);
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
const added = await svnAdd({ cwd: fixture.wc, paths: ["vanishing-add.txt"] });
|
|
639
|
+
expect(added.ok).toBe(false);
|
|
640
|
+
expect(added.note).toContain("path stat failed before svn command");
|
|
641
|
+
|
|
642
|
+
const imported = await svnImport({
|
|
643
|
+
cwd: fixture.wc,
|
|
644
|
+
src: vanishingImport,
|
|
645
|
+
url: `${pathToFileURL(fixture.repo).href}/imported`,
|
|
646
|
+
message: commitMessage("Import stat race")
|
|
647
|
+
});
|
|
648
|
+
expect(imported.ok).toBe(false);
|
|
649
|
+
expect(imported.note).toContain("path stat failed before svn command");
|
|
650
|
+
} finally {
|
|
651
|
+
statSpy?.mockRestore();
|
|
652
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("reverts mixed file and directory targets without applying recursive depth to files", async () => {
|
|
657
|
+
const fixture = createTempWorkingCopy();
|
|
658
|
+
try {
|
|
659
|
+
fs.mkdirSync(path.join(fixture.wc, "dir"), { recursive: true });
|
|
660
|
+
fs.writeFileSync(path.join(fixture.wc, "dir", "nested.txt"), "one\r\n", "utf8");
|
|
661
|
+
fs.writeFileSync(path.join(fixture.wc, "file.txt"), "one\r\n", "utf8");
|
|
662
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["dir/nested.txt"] })).ok).toBe(true);
|
|
663
|
+
expect((await svnAdd({ cwd: fixture.wc, paths: ["file.txt"] })).ok).toBe(true);
|
|
664
|
+
expect((await svnCommit({
|
|
665
|
+
cwd: fixture.wc,
|
|
666
|
+
paths: ["dir/nested.txt", "file.txt"],
|
|
667
|
+
message: commitMessage("Add revert fixture"),
|
|
668
|
+
riskAck: true
|
|
669
|
+
})).ok).toBe(true);
|
|
670
|
+
|
|
671
|
+
fs.writeFileSync(path.join(fixture.wc, "dir", "nested.txt"), "two\r\n", "utf8");
|
|
672
|
+
fs.writeFileSync(path.join(fixture.wc, "file.txt"), "two\r\n", "utf8");
|
|
673
|
+
const reverted = await svnRevert({ cwd: fixture.wc, paths: ["dir", "file.txt"], allowRecursive: true, dryRun: false });
|
|
674
|
+
|
|
675
|
+
expect(reverted.ok).toBe(true);
|
|
676
|
+
expect(reverted.command).toContain("--depth infinity");
|
|
677
|
+
expect(fs.readFileSync(path.join(fixture.wc, "dir", "nested.txt"), "utf8")).toBe("one\r\n");
|
|
678
|
+
expect(fs.readFileSync(path.join(fixture.wc, "file.txt"), "utf8")).toBe("one\r\n");
|
|
679
|
+
} finally {
|
|
680
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
function createTempWorkingCopy(): { root: string; repo: string; wc: string } {
|
|
686
|
+
const root = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), "svn-agent-it-"));
|
|
687
|
+
const repo = path.join(root, "repo");
|
|
688
|
+
const wc = path.join(root, "wc");
|
|
689
|
+
execFileSync(svnAdminExecutable(), ["create", repo], { cwd: root });
|
|
690
|
+
execFileSync(svnExecutable(), ["checkout", pathToFileURL(repo).href, wc], { cwd: root });
|
|
691
|
+
return { root, repo, wc };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function expectSvnArgs(command: string, argsPrefix: string): void {
|
|
695
|
+
expect(command).toContain(` ${argsPrefix}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function commitMessage(summary: string): string {
|
|
699
|
+
return `${summary}\n\n- Test fixture change\n- Verified by integration test\n- No behavior changes\n`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function normalizeStatusPath(statusPath: string, cwd: string): string {
|
|
703
|
+
const relative = path.isAbsolute(statusPath) ? path.relative(cwd, statusPath) : statusPath;
|
|
704
|
+
return relative.replace(/\\/g, "/");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function statusByPath(changedPaths: Array<{ path: string; status: string }>, cwd: string): Map<string, string> {
|
|
708
|
+
return new Map(changedPaths.map((entry) => [normalizeStatusPath(entry.path, cwd), entry.status]));
|
|
709
|
+
}
|