Files
AgentHub/server/__tests__/git-rewrite.test.js
T
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00

614 lines
23 KiB
JavaScript

/**
* @file History rewrites, remote writes and the better-conflicts reads
* (git-rewrite.js, git-conflicts.js) against REAL temp repos with a local bare
* repo as `origin`: the merge editor's stage reads + save + resolve, the
* reflog, rebase (clean, conflict → continue/skip/abort, EMERGING for other
* writes), interactive rebase (squash/reword/drop/reorder, EPUSHED, undo),
* reset (soft/mixed/keep/hard guards, undo), force-with-lease (ok, ESTALE),
* protected-branch refusals, remote branch delete and remotes add/rename/
* remove with the URL allowlist.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const { readConflict, readReflog } = require("../lib/git-conflicts");
const { rewritePlan, listRemotes, redactUrl } = require("../lib/git-rewrite");
const { contentSha } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-rewrite-"));
const REPO = path.join(ROOT, "repo");
const BARE = path.join(ROOT, "origin.git");
const OTHER = path.join(ROOT, "other");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const env = () => {
const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) delete e[k];
return e;
};
const gIn = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", env: env() });
const g = (...args) => gIn(REPO, ...args);
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const sha = (ref) => g("rev-parse", ref).trim();
const subjects = (range = "main") => g("log", "--format=%s", range).trim().split("\n");
const commit = (file, body, msg) => {
write(file, body);
g("add", "-A");
g("commit", "-qm", msg);
};
beforeEach(() => {
for (const d of [REPO, BARE, OTHER]) fs.rmSync(d, { recursive: true, force: true });
gIn(ROOT, "init", "-q", "--bare", "-b", "main", BARE);
fs.mkdirSync(REPO);
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
commit("a.txt", "base\n", "init");
g("remote", "add", "origin", BARE);
g("push", "-q", "-u", "origin", "main");
});
/** A `feat` branch and a `main` that both edit a.txt (one line). */
function divergent() {
g("switch", "-qc", "feat");
commit("a.txt", "theirs\n", "feat edit");
commit("f.txt", "f\n", "feat file");
g("switch", "-q", "main");
commit("a.txt", "ours\n", "main edit");
}
describe("merge editor reads and save", () => {
it("reads base/ours/theirs and saves a resolved result", async () => {
divergent();
const m = await runGitAction(REPO, "merge", { branch: "feat" });
assert.deepEqual(m.conflicts, ["a.txt"]);
const c = await readConflict(REPO, "a.txt");
assert.equal(c.base, "base\n");
assert.equal(c.ours, "ours\n");
assert.equal(c.theirs, "theirs\n");
assert.equal(c.operation, "merge");
assert.equal(c.usable, true);
assert.match(c.working, /<<<<<<< /);
assert.equal(c.sha, contentSha(fs.readFileSync(path.join(REPO, "a.txt"))));
assert.equal(c.incoming.subject, "feat file");
await assert.rejects(readConflict(REPO, "f.txt"), { code: "EBADPATH" });
await assert.rejects(readConflict(REPO, "../x"), { code: "EBADPATH" });
// Markers left: refused before anything is written.
await assert.rejects(
runGitAction(REPO, "conflict-save", { path: "a.txt", content: c.working, baseSha: c.sha }),
{ code: "EMARKERS" }
);
await assert.rejects(
runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "x\n",
baseSha: "0".repeat(40),
}),
{ code: "ESTALE" }
);
const r = await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "ours\ntheirs\n",
baseSha: c.sha,
});
assert.equal(r.resolved, true);
assert.equal(read("a.txt"), "ours\ntheirs\n");
assert.equal((await gitFacts(REPO)).conflicts, 0);
// No longer conflicted: neither readable nor savable here.
await assert.rejects(readConflict(REPO, "a.txt"), { code: "EBADPATH" });
await runGitAction(REPO, "merge-commit", {});
});
it("saves progress without resolving when resolve is false", async () => {
divergent();
await runGitAction(REPO, "merge", { branch: "feat" });
const c = await readConflict(REPO, "a.txt");
await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: c.working,
baseSha: c.sha,
resolve: false,
});
assert.equal((await gitFacts(REPO)).conflicts, 1);
});
});
describe("reflog", () => {
it("pages HEAD's and a branch's reflog and refuses an unknown ref", async () => {
commit("b.txt", "1\n", "second");
commit("b.txt", "2\n", "third");
const page = await readReflog(REPO, { limit: 2 });
assert.equal(page.entries.length, 2);
assert.equal(page.more, true);
assert.equal(page.entries[0].action, "commit");
assert.equal(page.entries[0].message, "third");
assert.equal(page.entries[0].selector, "HEAD@{0}");
assert.ok(page.entries[0].time > 0);
const next = await readReflog(REPO, { skip: 2, limit: 2 });
assert.equal(next.entries[0].index, 2);
const br = await readReflog(REPO, { ref: "main" });
assert.equal(br.ref, "main");
assert.ok(br.entries.length >= 3);
await assert.rejects(readReflog(REPO, { ref: "nope" }), { code: "ENOBRANCH" });
await assert.rejects(readReflog(REPO, { ref: "-x" }), { code: "EBADBRANCH" });
});
});
describe("rebase", () => {
it("rebases cleanly and offers an undo", async () => {
g("switch", "-qc", "feat");
commit("f.txt", "f\n", "feat file");
g("switch", "-q", "main");
commit("m.txt", "m\n", "main file");
g("switch", "-q", "feat");
const plan = await rewritePlan(REPO, { kind: "rebase", onto: "main" });
assert.equal(plan.commits.length, 1);
assert.equal(plan.behind, 1);
const r = await runGitAction(REPO, "rebase", { onto: "main", head: plan.head });
assert.deepEqual(r.conflicts, []);
assert.equal(r.before, plan.head);
assert.deepEqual(subjects("HEAD"), ["feat file", "main file", "init"]);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, plan.head);
});
it("stops on a conflict; other writes answer EMERGING; continue finishes it", async () => {
divergent();
g("switch", "-q", "feat");
const r = await runGitAction(REPO, "rebase", { onto: "main", head: sha("HEAD") });
assert.deepEqual(r.conflicts, ["a.txt"]);
const facts = await gitFacts(REPO);
assert.equal(facts.operation, "rebase");
assert.equal(facts.conflicts, 1);
for (const [action, body] of [
["commit", { message: "x" }],
["switch", { branch: "main" }],
["merge", { branch: "main" }],
["reset", { target: sha("HEAD"), mode: "soft", head: sha("HEAD"), confirm: true }],
["rebase", { onto: "main", head: sha("HEAD") }],
["push", {}],
// Shelves and the Branches & tags tools wait for the rebase too.
["shelve", { confirm: true }],
["unshelve", { sha: "abcd", confirm: true }],
["shelf-drop", { sha: "abcd", confirm: true }],
["tag-create", { tag: "v1" }],
["branch-rename", { branch: "feat", to: "feat2" }],
]) {
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }, action);
}
await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ECONFLICTS" });
const c = await readConflict(REPO, "a.txt");
assert.equal(c.operation, "rebase");
await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "merged\n",
baseSha: c.sha,
});
const done = await runGitAction(REPO, "rebase-continue", {});
assert.equal(done.stopped, false);
assert.equal((await gitFacts(REPO)).merging, false);
assert.deepEqual(subjects("HEAD"), ["feat file", "feat edit", "main edit", "init"]);
assert.equal(read("a.txt"), "merged\n");
});
it("skip drops the stopped commit; abort needs confirm and restores", async () => {
divergent();
g("switch", "-q", "feat");
const before = sha("HEAD");
await runGitAction(REPO, "rebase", { onto: "main", head: before });
const s = await runGitAction(REPO, "rebase-skip", {});
assert.equal(s.stopped, false);
assert.deepEqual(subjects("HEAD"), ["feat file", "main edit", "init"]);
g("reset", "-q", "--hard", before);
await runGitAction(REPO, "rebase", { onto: "main", head: before });
await assert.rejects(runGitAction(REPO, "rebase-abort", {}), { code: "ECONFIRM" });
// The banner's Abort (merge-abort) aborts a rebase too.
await runGitAction(REPO, "merge-abort", { confirm: true });
assert.equal(sha("HEAD"), before);
await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ENOREBASE" });
});
it("refuses pushed commits without force, a dirty tree, a stale head and a bad ref", async () => {
g("switch", "-qc", "feat");
commit("f.txt", "f\n", "feat file");
g("push", "-q", "-u", "origin", "feat");
g("switch", "-q", "main");
commit("m.txt", "m\n", "main file");
g("switch", "-q", "feat");
const head = sha("HEAD");
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head }), {
code: "EPUSHED",
});
await assert.rejects(runGitAction(REPO, "rebase", { onto: "nope", head }), {
code: "ENOBRANCH",
});
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head: "0".repeat(40) }), {
code: "ESTALE",
});
write("f.txt", "dirty\n");
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head, force: true }), {
code: "EDIRTY",
});
g("checkout", "--", "f.txt");
const r = await runGitAction(REPO, "rebase", { onto: "main", head, force: true });
assert.equal(r.needsForcePush, true);
});
it("refuses a subdirectory lane", async () => {
fs.mkdirSync(path.join(REPO, "sub"));
commit("sub/s.txt", "s\n", "sub");
await assert.rejects(
runGitAction(path.join(REPO, "sub"), "rebase", { onto: "main", head: sha("HEAD") }),
{ code: "ESUBDIR" }
);
});
});
describe("interactive rebase", () => {
beforeEach(() => {
commit("1.txt", "1\n", "one");
commit("2.txt", "2\n", "two");
commit("3.txt", "3\n", "three");
commit("4.txt", "4\n", "four");
});
it("plans only the unpushed commits by default", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
assert.deepEqual(
plan.commits.map((c) => c.subject),
["one", "two", "three", "four"]
);
assert.equal(plan.pushed, 0);
assert.equal(plan.base, sha("origin/main"));
});
it("squashes, rewords, drops and reorders without an editor", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const [one, two, three, four] = plan.commits;
const r = await runGitAction(REPO, "rebase-interactive", {
head: plan.head,
confirm: true,
todo: [
{
sha: four.sha,
action: "reword",
message: "four, first now\n\nwith a body; $(rm -rf x) 'q'",
},
{ sha: one.sha, action: "pick" },
{ sha: two.sha, action: "squash" },
{ sha: three.sha, action: "drop" },
],
});
assert.deepEqual(r.conflicts, []);
assert.equal(r.stopped, false);
assert.deepEqual(subjects("HEAD"), ["one", "four, first now", "init"]);
assert.equal(g("log", "-1", "--format=%B", "HEAD").trim(), "one\n\ntwo");
assert.match(g("log", "-1", "--format=%B", "HEAD~1"), /\$\(rm -rf x\) 'q'/);
assert.equal(fs.existsSync(path.join(REPO, "3.txt")), false);
assert.equal(fs.existsSync(path.join(REPO, "2.txt")), true);
// No scratch file is left in the git dir.
assert.deepEqual(
fs.readdirSync(path.join(REPO, ".git")).filter((n) => n.startsWith("agenthub-rebase-")),
[]
);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, plan.head);
});
it("squash with a message and fixup keep exactly what was asked", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const [one, two, three, four] = plan.commits;
await runGitAction(REPO, "rebase-interactive", {
head: plan.head,
confirm: true,
todo: [
{ sha: one.sha, action: "pick" },
{ sha: two.sha, action: "fixup" },
{ sha: three.sha, action: "squash", message: "one to three" },
{ sha: four.sha, action: "pick" },
],
});
assert.deepEqual(subjects("HEAD"), ["four", "one to three", "init"]);
});
it("refuses bad todos, no change, pushed commits and needs confirm", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const shas = plan.commits.map((c) => c.sha);
const base = { head: plan.head, confirm: true };
const picks = shas.map((s) => ({ sha: s, action: "pick" }));
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, confirm: false, todo: picks }),
{
code: "ECONFIRM",
}
);
await assert.rejects(runGitAction(REPO, "rebase-interactive", { ...base, todo: picks }), {
code: "ENOCHANGES",
});
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, todo: picks.slice(1) }),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "squash" }, ...picks.slice(1)],
}),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "exec rm" }, ...picks.slice(1)],
}),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "reword" }, ...picks.slice(1)],
}),
{ code: "EBADMESSAGE" }
);
// Push two of them: a base before them is now EPUSHED without force.
g("push", "-q", "origin", `${shas[1]}:refs/heads/main`);
const wide = await rewritePlan(REPO, { kind: "interactive", base: sha("main~4") });
assert.equal(wide.pushed, 2);
const reversed = [...wide.commits].reverse().map((c) => ({ sha: c.sha, action: "pick" }));
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, base: wide.base, todo: reversed }),
{ code: "EPUSHED" }
);
// The default base now starts after the pushed ones.
const narrow = await rewritePlan(REPO, { kind: "interactive" });
assert.deepEqual(
narrow.commits.map((c) => c.subject),
["three", "four"]
);
});
});
describe("reset", () => {
beforeEach(() => {
commit("1.txt", "1\n", "one");
commit("2.txt", "2\n", "two");
});
const reset = (mode, extra = {}) =>
runGitAction(REPO, "reset", {
target: sha("HEAD~2"),
mode,
head: sha("HEAD"),
confirm: true,
...extra,
});
it("soft keeps the changes staged, mixed unstaged; undo puts the branch back", async () => {
const plan = await rewritePlan(REPO, { kind: "reset", target: sha("HEAD~2") });
assert.equal(plan.dropped, 2);
assert.equal(plan.pushed, 0);
assert.equal(plan.related, true);
const r = await reset("soft");
assert.match(g("status", "--porcelain"), /^A {2}1\.txt/m);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, r.before);
await reset("mixed");
assert.match(g("status", "--porcelain"), /^\?\? 1\.txt/m);
});
it("keep refuses to overwrite a local change; hard needs a clean tree and the branch name", async () => {
write("2.txt", "local\n");
await assert.rejects(
reset("keep"),
(e) => typeof e.code === "number" || e.code === 128 || !!e.git
);
assert.equal(read("2.txt"), "local\n");
await assert.rejects(reset("hard", { confirmBranch: "main" }), { code: "EDIRTY" });
g("checkout", "--", "2.txt");
await assert.rejects(reset("hard"), { code: "ECONFIRM" });
await assert.rejects(reset("hard", { confirmBranch: "mian" }), { code: "ECONFIRM" });
const r = await reset("hard", { confirmBranch: "main" });
assert.equal(r.mode, "hard");
assert.equal(fs.existsSync(path.join(REPO, "1.txt")), false);
});
it("refuses pushed commits without force, an unrelated target, a bad mode, no confirm", async () => {
g("push", "-q", "origin", "main");
await assert.rejects(reset("soft"), { code: "EPUSHED" });
const r = await reset("soft", { force: true });
assert.equal(r.needsForcePush, true);
g("reset", "-q", "--hard", r.before);
await assert.rejects(reset("bogus"), { code: "EBADMODE" });
await assert.rejects(reset("soft", { confirm: false }), { code: "ECONFIRM" });
g("switch", "-q", "--orphan", "lonely");
commit("z.txt", "z\n", "lonely");
await assert.rejects(
runGitAction(REPO, "reset", {
target: sha("main"),
mode: "soft",
head: sha("HEAD"),
confirm: true,
}),
{ code: "ENOTRELATED" }
);
});
});
describe("force push and remote branches", () => {
function pushedFeat() {
g("switch", "-qc", "feat");
commit("f.txt", "1\n", "f1");
g("push", "-q", "-u", "origin", "feat");
// Rewrite it locally so a plain push is rejected.
g("commit", "-q", "--amend", "-m", "f1 amended");
}
it("force-pushes with a lease on the sha the plan showed", async () => {
pushedFeat();
const plan = await rewritePlan(REPO, { kind: "push" });
assert.equal(plan.remote, "origin");
assert.equal(plan.remoteBranch, "feat");
assert.equal(plan.remoteSha, sha("origin/feat"));
assert.equal(plan.allowed, true);
await assert.rejects(runGitAction(REPO, "force-push", { expect: plan.remoteSha }), {
code: "ECONFIRM",
});
await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true });
assert.equal(gIn(BARE, "rev-parse", "feat").trim(), sha("HEAD"));
});
it("answers ESTALE when the remote moved", async () => {
pushedFeat();
const expect = sha("origin/feat");
gIn(ROOT, "clone", "-q", "-b", "feat", BARE, OTHER);
gIn(
OTHER,
"-c",
"user.email=o@h",
"-c",
"user.name=o",
"commit",
"-q",
"--allow-empty",
"-m",
"other"
);
gIn(OTHER, "push", "-q", "origin", "feat");
g("fetch", "-q");
await assert.rejects(runGitAction(REPO, "force-push", { expect, confirm: true }), {
code: "ESTALE",
});
});
it("refuses a protected branch unless the server allows it", async () => {
commit("x.txt", "x\n", "x");
const plan = await rewritePlan(REPO, { kind: "push" });
assert.equal(plan.protected, true);
assert.equal(plan.allowed, false);
await assert.rejects(
runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true }),
{ code: "EPROTECTED" }
);
process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED = "1";
try {
await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true });
} finally {
delete process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED;
}
assert.equal(gIn(BARE, "rev-parse", "main").trim(), sha("HEAD"));
});
it("deletes a remote branch but never the default or a protected one", async () => {
g("push", "-q", "origin", "main:refs/heads/topic", "main:refs/heads/develop");
g("remote", "set-head", "origin", "main");
g("fetch", "-q");
await assert.rejects(runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic" }), {
code: "ECONFIRM",
});
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/develop", confirm: true }),
{ code: "EPROTECTED" }
);
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/nope", confirm: true }),
{ code: "ENOBRANCH" }
);
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "--all", confirm: true }),
{ code: "EBADBRANCH" }
);
// A non-protected default branch is refused too.
g("push", "-q", "origin", "main:refs/heads/trunk");
gIn(BARE, "symbolic-ref", "HEAD", "refs/heads/trunk");
g("remote", "set-head", "origin", "trunk");
g("fetch", "-q");
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/trunk", confirm: true }),
{ code: "EDEFAULTBRANCH" }
);
await runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic", confirm: true });
assert.throws(() => gIn(BARE, "rev-parse", "--verify", "-q", "topic"));
});
});
describe("remotes", () => {
it("adds, renames and removes with the URL allowlist", async () => {
for (const url of [
"file:///tmp/x",
"ext::sh -c touch% /tmp/pwn",
"-xfoo",
"/tmp/local",
"http://h/x",
"https://u:p@h/x",
]) {
await assert.rejects(runGitAction(REPO, "remote-add", { name: "up", url }), {
code: "EBADURL",
});
}
for (const name of ["-x", "a b", "", "x..y", "x.lock"]) {
await assert.rejects(
runGitAction(REPO, "remote-add", { name, url: "https://example.com/r.git" }),
{ code: "EBADREMOTE" }
);
}
await assert.rejects(
runGitAction(REPO, "remote-add", { name: "origin", url: "https://example.com/r.git" }),
{ code: "EREMOTEEXISTS" }
);
await runGitAction(REPO, "remote-add", { name: "up", url: "git@example.com:o/r.git" });
await runGitAction(REPO, "remote-rename", { name: "up", to: "upstream" });
await assert.rejects(runGitAction(REPO, "remote-rename", { name: "upstream", to: "origin" }), {
code: "EREMOTEEXISTS",
});
let list = await listRemotes(REPO);
assert.deepEqual(
list.map((r) => r.name),
["origin", "upstream"]
);
await assert.rejects(runGitAction(REPO, "remote-remove", { name: "upstream" }), {
code: "ECONFIRM",
});
await runGitAction(REPO, "remote-remove", { name: "upstream", confirm: true });
list = await listRemotes(REPO);
assert.deepEqual(
list.map((r) => r.name),
["origin"]
);
await assert.rejects(runGitAction(REPO, "remote-remove", { name: "gone", confirm: true }), {
code: "EBADREMOTE",
});
});
it("never lists a password", () => {
assert.equal(redactUrl("https://bob:tok@h.com/x.git"), "https://h.com/x.git");
assert.equal(redactUrl("https://tok@h.com/x.git"), "https://h.com/x.git");
assert.equal(redactUrl("ssh://git:pw@h.com/x.git"), "ssh://git@h.com/x.git");
assert.equal(redactUrl("git@h.com:x.git"), "git@h.com:x.git");
});
});