bed495a9 Andy McCall 1 month ago ↶
1
// The process wrapper against the real oxy binary on a scratch repository.
2
// Runs with `npm test`; needs oxy on PATH, at ~/.local/bin/oxy, or in OXY_BIN.
3
4
import { test } from "node:test";
5
import assert from "node:assert/strict";
dac6876e Andy McCall 6 days ago ↶
6
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
bed495a9 Andy McCall 1 month ago ↶
7
import { tmpdir } from "node:os";
8
import { join } from "node:path";
dac6876e Andy McCall 6 days ago ↶
9
import { Oxy, findBinary, isRemoteBookmark, mergeChoices, parseKeptBoth } from "../out/oxy.mjs";
bed495a9 Andy McCall 1 month ago ↶
10
11
const binary = process.env.OXY_BIN ?? findBinary(undefined);
12
const oxy = new Oxy(binary);
13
dac6876e Andy McCall 6 days ago ↶
14
/** Whether the oxy under test is at least `min` (a feature newer than the oldest oxy the tests run with). */
15
const oxyVersion = (await oxy.version()).version;
16
function atLeast(min) {
17
const [a, b] = [oxyVersion, min].map((v) => v.split(/[.-]/).slice(0, 3).map(Number));
18
for (let i = 0; i < 3; i++) {
19
if (a[i] !== b[i]) {
20
return a[i] > b[i];
21
}
22
}
23
return true;
24
}
25
bed495a9 Andy McCall 1 month ago ↶
26
test("root, status, cat, log and commit round trip", async () => {
27
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-"));
28
const home = join(dir, "home");
29
mkdirSync(home);
30
process.env.OXY_HOME = home;
31
await oxy.check(dir, ["init", "repo"]);
32
const root = join(dir, "repo");
33
await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
34
35
const version = await oxy.version();
36
assert.equal(version.protocol, 1);
37
assert.equal(await oxy.root(dir), null, "outside a repository");
38
const info = await oxy.root(root);
39
assert.equal(info?.bookmark, "main");
40
assert.ok(info?.root.endsWith("repo"));
41
42
writeFileSync(join(root, "a.txt"), "one\n");
43
let status = await oxy.status(root);
44
assert.deepEqual(status.paths, [{ path: "a.txt", status: "Added" }]);
45
assert.equal(status.clean, false);
46
await oxy.commit(root, "first");
47
status = await oxy.status(root);
48
assert.equal(status.clean, true);
49
50
writeFileSync(join(root, "a.txt"), "one\ntwo\n");
51
assert.equal((await oxy.cat(root, "a.txt")).toString(), "one\n", "the base version");
52
const log = await oxy.log(root, 10);
53
assert.equal(log.changes.filter((c) => !c.working_copy).length, 1);
54
assert.equal(log.changes.find((c) => !c.working_copy)?.signature?.status, "unsigned");
55
assert.ok((await oxy.prompt(root)).includes("main"));
56
const bookmarks = await oxy.bookmarks(root);
57
assert.deepEqual(bookmarks.map((b) => [b.name, b.current]), [["main", true]]);
58
await assert.rejects(oxy.cat(root, "missing.txt"), /not in that state/);
35d56b58 Andy McCall 1 month ago ↶
59
5779e5c7 Andy McCall 1 month ago ↶
60
// Amend folds the edit into the single head.
61
await oxy.amend(root, "first, amended");
62
assert.equal((await oxy.status(root)).clean, true);
63
assert.ok((await oxy.log(root, 10)).changes.some((c) => c.message === "first, amended"));
64
65
// Hunks: commit only b.txt's, leave a.txt's edit, then discard it.
66
writeFileSync(join(root, "a.txt"), "one\ntwo\nthree\n");
35d56b58 Andy McCall 1 month ago ↶
67
writeFileSync(join(root, "b.txt"), "b\n");
68
const hunks = await oxy.hunks(root);
69
assert.ok(hunks.length >= 2, JSON.stringify(hunks));
70
const bHunk = hunks.find((h) => h.path === "b.txt");
71
await oxy.commitHunks(root, "b only", [bHunk.index]);
72
status = await oxy.status(root);
73
assert.deepEqual(status.paths.map((p) => p.path), ["a.txt"], "a.txt stays uncommitted");
5779e5c7 Andy McCall 1 month ago ↶
74
assert.equal((await oxy.cat(root, "b.txt")).toString(), "b\n", "committed with its content");
35d56b58 Andy McCall 1 month ago ↶
75
await oxy.discard(root, ["a.txt"]);
76
assert.equal((await oxy.status(root)).clean, true);
5779e5c7 Andy McCall 1 month ago ↶
77
78
// The operation log, and undo of the last operation (the commit).
35d56b58 Andy McCall 1 month ago ↶
79
const ops = await oxy.opLog(root);
80
assert.ok(ops[0].current && ops.length > 3);
81
assert.match(await oxy.undo(root), /Undone/);
5779e5c7 Andy McCall 1 month ago ↶
82
status = await oxy.status(root);
83
assert.ok(status.paths.some((p) => p.path === "b.txt"), "the commit is undone; b.txt is uncommitted work again");
bed495a9 Andy McCall 1 month ago ↶
84
});
c8f95589 Andy McCall 1 month ago ↶
85
86
test("bookmarks, remotes and sync against a local origin", async () => {
87
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-sync-"));
88
const home = join(dir, "home");
89
mkdirSync(home);
90
process.env.OXY_HOME = home;
91
await oxy.check(dir, ["init", "origin"]);
92
await oxy.clone(dir, join(dir, "origin"), join(dir, "work"));
93
const root = join(dir, "work");
94
writeFileSync(join(root, "a.txt"), "a\n");
95
await oxy.commit(root, "a");
96
const remotes = await oxy.remotes(root);
97
assert.equal(remotes[0].name, "origin");
98
let push = await oxy.push(root);
99
assert.equal(push[0].ok, true);
100
let bookmarks = await oxy.bookmarks(root);
101
assert.deepEqual(bookmarks.map((b) => [b.name, b.current, b.ahead, b.behind]), [["main", true, 0, 0]]);
102
103
await oxy.bookmarkCreate(root, "feature");
104
await oxy.bookmarkSwitch(root, "feature");
105
writeFileSync(join(root, "a.txt"), "a\nfeature\n");
106
await oxy.commit(root, "feature work");
107
bookmarks = await oxy.bookmarks(root);
108
assert.equal(bookmarks.find((b) => b.current)?.name, "feature");
109
await oxy.bookmarkSwitch(root, "main");
110
assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\n");
111
writeFileSync(join(root, "b.txt"), "b\n");
112
await assert.rejects(oxy.bookmarkSwitch(root, "feature"), /has changes/);
113
await oxy.commit(root, "b on main");
114
assert.match(await oxy.merge(root, "feature"), /Merged/);
115
assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\nfeature\n");
116
push = await oxy.push(root, "origin", "feature");
117
assert.equal(push[0].ok, true);
118
push = await oxy.push(root);
119
assert.equal(push[0].ok, true);
120
const fetch = await oxy.fetch(root);
121
assert.equal(fetch.remote, "origin");
122
const pull = await oxy.pull(root);
123
assert.equal(pull.changed, false);
124
await oxy.bookmarkDelete(root, "feature");
125
bookmarks = await oxy.bookmarks(root);
126
assert.deepEqual(bookmarks.map((b) => b.name), ["main"]);
ab81294a Andy McCall 1 month ago ↶
127
128
// Blame, show, diff text, and file history.
129
const blame = await oxy.blame(root, "a.txt");
130
assert.deepEqual(blame.map((l) => l.line), [1, 2]);
131
assert.notEqual(blame[0].hash, blame[1].hash, "two changes introduced the two lines");
132
const show = await oxy.show(root, `#${blame[1].hash.slice(0, 12)}`);
133
assert.equal(show.message, "feature work");
134
assert.deepEqual(show.files.map((f) => f.path), ["a.txt"]);
135
assert.match(await oxy.diffText(root, `#${blame[1].hash.slice(0, 12)}`), /\+feature/);
136
const history = await oxy.log(root, 50, "a.txt");
137
assert.equal(history.changes.filter((c) => !c.working_copy).length, 2);
d2c9a584 Andy McCall 1 month ago ↶
138
139
// Tags, workspaces, trust, bisect status, subrepos.
140
await oxy.tagCreate(root, "v1", [], "first");
141
const tags = await oxy.tags(root);
142
assert.deepEqual(tags.map((t) => [t.name, t.message]), [["v1", "first"]]);
143
const workspaces = await oxy.workspaces(root);
144
assert.equal(workspaces.length, 1);
145
assert.equal(workspaces[0].current, true);
146
assert.equal(await oxy.policyShow(root), null, "no policy yet");
147
const verify = await oxy.verify(root);
148
assert.ok(verify.every((v) => v.status === "unsigned"));
149
assert.equal(await oxy.bisectStatus(root), null);
150
assert.deepEqual(await oxy.submodules(root), []);
151
await oxy.bisectStart(root);
152
assert.equal((await oxy.bisectStatus(root))?.state, "need-marks");
153
await oxy.bisectReset(root);
c8f95589 Andy McCall 1 month ago ↶
154
});
155
b4626290 Andy McCall 1 month ago ↶
156
157
test("split, squash and rebase through the history actions, each undoable", async () => {
158
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-hist-"));
159
const home = join(dir, "home");
160
mkdirSync(home);
161
process.env.OXY_HOME = home;
162
await oxy.check(dir, ["init", "repo"]);
163
const root = join(dir, "repo");
164
await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
165
writeFileSync(join(root, "a.txt"), "a\n");
166
await oxy.commit(root, "one");
167
writeFileSync(join(root, "b.txt"), "b\n");
168
writeFileSync(join(root, "c.txt"), "c\n");
169
await oxy.commit(root, "two files");
170
const find = async (m) => (await oxy.log(root, 50)).changes.find((c) => !c.working_copy && c.message.startsWith(m));
171
172
// Split: b.txt becomes its own change, c.txt stays.
173
const two = await find("two files");
174
const out = await oxy.split(root, `#${two.hash.slice(0, 12)}`, ["b.txt"]);
175
assert.match(out, /Split into/);
176
const part = await find("two files (part 1");
177
assert.ok(part, "the first part exists");
178
assert.deepEqual((await oxy.show(root, `#${part.hash.slice(0, 12)}`)).files.map((f) => f.path), ["b.txt"]);
179
await oxy.undo(root);
180
assert.equal(await find("two files (part 1"), undefined, "undo reverses the split");
181
182
// Squash: an edit of a.txt folds into "one", which keeps its ChangeId.
183
writeFileSync(join(root, "a.txt"), "a\na2\n");
184
await oxy.commit(root, "edit a");
185
const one = await find("one");
186
const edit = await find("edit a");
187
assert.deepEqual(edit.deps, [one.hash]);
188
assert.match(await oxy.squash(root, `#${edit.hash.slice(0, 12)}`, `#${one.hash.slice(0, 12)}`), /Squashed into/);
189
const after = await find("one");
190
assert.equal(after.change_id, one.change_id);
191
assert.equal(await find("edit a"), undefined);
192
193
// Rebase onto a state that already has the change is refused, with oxy's reason.
194
await assert.rejects(oxy.rebase(root, `#${after.hash.slice(0, 12)}`, ["main"]), /already part of the target state/);
195
});
44bfd5ad Andy McCall 1 month ago ↶
196
197
test("identity reports how commits are signed", async () => {
198
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-id-"));
199
const home = join(dir, "home");
200
mkdirSync(home);
201
process.env.OXY_HOME = home;
202
await oxy.check(dir, ["init", "repo"]);
203
const root = join(dir, "repo");
204
assert.equal(await oxy.identity(root), null, "none yet");
205
await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
206
const id = await oxy.identity(root);
207
assert.equal(id?.who, "Test <[email protected] >");
208
assert.equal(id?.secret, true);
209
assert.ok(id?.mode === "change" || id?.mode === "seal");
dac6876e Andy McCall 6 days ago ↶
210
});
211
212
213
// ---------------------------------------------------------------- oxy 0.5 to 0.9 (oxygen-vscode#11)
214
215
test("oxy resolve --both's report is read", () => {
216
const text = [
217
"kept both CHANGELOG.md: conflict 1, 3",
218
"markers CHANGELOG.md: conflict 2: both sides changed these lines; edit the markers",
219
"resolved CHANGELOG.md",
220
"markers src/a.rs",
221
"All conflicts are resolved; `oxy commit` will record the resolution.",
222
].join("\n");
223
assert.deepEqual(parseKeptBoth(text), {
224
kept: [{ path: "CHANGELOG.md", conflicts: [1, 3] }],
225
left: [{ path: "CHANGELOG.md", conflict: 2, why: "both sides changed these lines; edit the markers" }],
226
});
227
assert.deepEqual(parseKeptBoth(""), { kept: [], left: [] });
228
});
229
230
test("Merge Bookmark offers the other local bookmarks, then remotes' bookmarks", () => {
231
const list = [
232
{ name: "main", heads: ["a"], current: true, ahead: 0, behind: 0, remote: "origin" },
233
{ name: "feature/x", heads: ["b"], current: false, ahead: null, behind: null, remote: null },
234
{ name: "origin/develop", heads: ["c", "d"], current: false, ahead: null, behind: null, remote: "origin" },
235
];
236
assert.deepEqual(list.map(isRemoteBookmark), [false, false, true]);
237
assert.deepEqual(mergeChoices(list), [
238
{ name: "feature/x", remote: null, heads: 1 },
239
{ name: "origin/develop", remote: "origin", heads: 2 },
240
]);
241
});
242
243
test("tags are withdrawn, signed and never reused", async () => {
244
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-tag-"));
245
const home = join(dir, "home");
246
mkdirSync(home);
247
process.env.OXY_HOME = home;
248
await oxy.check(dir, ["init", "repo"]);
249
const root = join(dir, "repo");
250
await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
251
writeFileSync(join(root, "a.txt"), "a\n");
252
await oxy.commit(root, "one");
253
await oxy.tagCreate(root, "v1", [], "first");
254
assert.match(await oxy.tagWithdraw(root, "v1", "built from the wrong state"), /Withdrew v1/);
255
assert.deepEqual(await oxy.tags(root), [], "a withdrawn tag is not listed");
256
await assert.rejects(oxy.tagCreate(root, "v1", [], "again"), /withdrawn/);
257
// No remote, so no hub whose transparency log to check: oxy says so.
258
await assert.rejects(oxy.tlogVerify(root), /hub/);
259
});
260
261
test("shallow clone, remote bookmarks merged, amend and commit by paths", async () => {
262
const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-paths-"));
263
const home = join(dir, "home");
264
mkdirSync(home);
265
process.env.OXY_HOME = home;
266
await oxy.check(dir, ["init", "origin"]);
267
const origin = join(dir, "origin");
268
await oxy.check(origin, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
269
writeFileSync(join(origin, "a.txt"), "a\n");
270
await oxy.commit(origin, "one");
271
await oxy.clone(dir, origin, join(dir, "work"), 0);
272
const root = join(dir, "work");
273
assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "a\n", "the shallow clone has the latest state");
274
275
// Amend only one path: the other stays uncommitted work.
276
writeFileSync(join(root, "a.txt"), "a\nmore\n");
277
await oxy.commit(root, "more");
278
writeFileSync(join(root, "a.txt"), "a\nmore\nagain\n");
279
writeFileSync(join(root, "c.txt"), "c\n");
280
await oxy.amend(root, undefined, ["a.txt"]);
281
const status = await oxy.status(root);
282
assert.deepEqual(status.paths.map((p) => p.path), ["c.txt"], "c.txt was not folded in");
283
assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\nmore\nagain\n");
284
rmSync(join(root, "c.txt"));
285
286
// A bookmark made on the origin is merged here as origin/feat, without a local bookmark.
287
await oxy.bookmarkCreate(origin, "feat");
288
await oxy.bookmarkSwitch(origin, "feat");
289
writeFileSync(join(origin, "f.txt"), "f\n");
290
await oxy.commit(origin, "feat work");
291
await oxy.fetch(root);
292
const all = await oxy.bookmarks(root, true);
293
assert.deepEqual(mergeChoices(all).map((c) => [c.name, c.remote]), [["origin/feat", "origin"]]);
294
assert.deepEqual((await oxy.bookmarks(root)).map((b) => b.name), ["main"], "without --all, local bookmarks only");
295
assert.match(await oxy.merge(root, "origin/feat"), /Merged/);
296
assert.equal(readFileSync(join(root, "f.txt"), "utf8"), "f\n");
297
});
298
299
/** A repository whose main and other bookmarks changed `t.txt` (inserting after the same line) and `bin.dat` two ways, merged. */
300
async function conflicted(prefix, text) {
301
const dir = mkdtempSync(join(tmpdir(), prefix));
302
const home = join(dir, "home");
303
mkdirSync(home);
304
process.env.OXY_HOME = home;
305
await oxy.check(dir, ["init", "repo"]);
306
const root = join(dir, "repo");
307
await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected] "]);
308
writeFileSync(join(root, "t.txt"), "a\nb\n");
309
writeFileSync(join(root, "u.txt"), "a\nb\n");
310
writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 3]));
311
await oxy.commit(root, "base");
312
await oxy.bookmarkCreate(root, "other");
313
writeFileSync(join(root, "t.txt"), text("X"));
314
writeFileSync(join(root, "u.txt"), "a\nX\n");
315
writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 4]));
316
await oxy.commit(root, "main side");
317
await oxy.bookmarkSwitch(root, "other");
318
writeFileSync(join(root, "t.txt"), text("Y"));
319
writeFileSync(join(root, "u.txt"), "a\nY\n");
320
writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 5]));
321
await oxy.commit(root, "other side");
322
await oxy.bookmarkSwitch(root, "main");
323
await oxy.merge(root, "other");
324
return root;
325
}
326
327
test("a binary file's conflict is settled by keeping one side, then its resolution committed by path", async () => {
328
const root = await conflicted("oxy-vscode-keep-", (s) => `a\n${s}\n`);
329
let status = await oxy.status(root);
330
const bin = status.conflicts.find((c) => c.path === "bin.dat");
331
assert.equal(bin.kind, "replace");
332
assert.equal(bin.changes.length, 2);
333
// Keep side 2 as Keep a Side does: its version in place, candidates gone, oxy resolve PATH.
334
const side = await oxy.cat(root, "bin.dat", bin.changes[1]);
335
writeFileSync(join(root, "bin.dat"), side);
336
for (const c of readdirSync(root).filter((e) => e.startsWith("bin.dat.oxy-conflict-"))) {
337
rmSync(join(root, c));
338
}
339
status = await oxy.resolvePaths(root, ["bin.dat"]);
340
assert.equal(status.conflicts.find((c) => c.path === "bin.dat").resolved, true);
341
assert.ok(status.conflicts.some((c) => c.path !== "bin.dat" && c.resolved !== true), "the text conflicts remain");
342
await assert.rejects(oxy.resolvePaths(root, ["u.txt"]), /no replace conflict/);
343
344
// Commit Resolution of Selected Paths: only bin.dat; the text conflicts stay for later.
345
await oxy.commit(root, "keep the other logo", ["bin.dat"]);
346
status = await oxy.status(root);
347
assert.equal(status.conflicts.some((c) => c.path === "bin.dat"), false);
348
assert.deepEqual(status.conflicts.map((c) => c.path).sort(), ["t.txt", "u.txt"]);
349
assert.deepEqual([...(await oxy.cat(root, "bin.dat"))], [...side]);
350
});
351
352
test("two changes adding lines at the same place are settled with one command", { skip: !atLeast("0.8.0") && `oxy ${oxyVersion} has no resolve --both` }, async () => {
353
const root = await conflicted("oxy-vscode-both-", (s) => `a\n${s}\nb\n`);
354
const result = await oxy.resolveBoth(root, ["t.txt", "u.txt"]);
355
assert.deepEqual(result.kept, [{ path: "t.txt", conflicts: [1] }]);
356
assert.deepEqual(result.left.map((l) => [l.path, l.conflict]), [["u.txt", 1]], "u.txt's sides changed the same line");
357
assert.match(result.left[0].why, /same lines|edit the markers/);
358
assert.equal(readFileSync(join(root, "t.txt"), "utf8"), "a\nX\nY\nb\n", "earliest change first");
359
const status = await oxy.status(root);
360
assert.equal(status.conflicts.find((c) => c.path === "t.txt").resolved, true);
44bfd5ad Andy McCall 1 month ago ↶
361
});