OXYGENHUB
◆ main oxygen-vscode / test / oxy.test.mjs
17.0 KiB · last changed Withdraw tags, verify the transparency log, shallow clones, keep a side or both sides of a conflict, commit and amend by paths, merge remote bookmarks (closes #11) 6 days ago source blame history raw
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";
6
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
7
import { tmpdir } from "node:os";
8
import { join } from "node:path";
9
import { Oxy, findBinary, isRemoteBookmark, mergeChoices, parseKeptBoth } from "../out/oxy.mjs";
10
11
const binary = process.env.OXY_BIN ?? findBinary(undefined);
12
const oxy = new Oxy(binary);
13
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
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/);
59
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");
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");
74
  assert.equal((await oxy.cat(root, "b.txt")).toString(), "b\n", "committed with its content");
75
  await oxy.discard(root, ["a.txt"]);
76
  assert.equal((await oxy.status(root)).clean, true);
77
78
  // The operation log, and undo of the last operation (the commit).
79
  const ops = await oxy.opLog(root);
80
  assert.ok(ops[0].current && ops.length > 3);
81
  assert.match(await oxy.undo(root), /Undone/);
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");
84
});
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"]);
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);
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);
154
});
155
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
});
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");
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);
361
});