"use strict";
const test = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const {
chooseGlobalBinDir, commitTransaction, completionTarget, dirFirstOnPath, dirOnPath,
ensureUsableInstallDir, acquireInstallLock, releaseInstallLock, writeCompletionFile, installCompletions, providerIsRunningPackage,
firstProviderOnPath, foreignChannelHint, foreignChannelTargetError, formatErrorChain,
helperPackageNameTransform, installHelp, isTransactionLeftoverName, parseInstallArgs,
persistWindowsUserPath, prependWindowsUserPath, quotePowerShell, replacePath,
rollbackTransaction, sweepTransactionLeftovers, validateWindowsPathDirectory, windowsPathGuidance,
windowsPathShadowing,
} = require("../lib/install");
test("copied update helper gets the npm coordinate injected and loads standalone", () => {
const helperSrc = fs.readFileSync(path.join(__dirname, "..", "lib", "bootstrap-update-helper.js"), "utf8");
for (const name of ["atomgit-cli", "@atomgit-cli/cli", "@gitcode-cli/cli"]) {
const rendered = helperPackageNameTransform(name)(helperSrc);
assert.ok(!rendered.includes('require("../package.json")'), `${name}: relative require must be rewritten`);
assert.ok(rendered.includes(`const PACKAGE = ${JSON.stringify(name)};`), `${name}: literal coordinate expected`);
const dir = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-helper-copy-"));
const copy = path.join(dir, "gitcode-update-helper.js");
fs.writeFileSync(copy, rendered, { mode: 0o755 });
const helper = require(copy);
const args = helper.withNpmIsolation(["exec", "--yes", "--", "gitcode", "install"], "u.npmrc", "g.npmrc");
assert.ok(args.includes("--registry=https://registry.npmjs.org"), `${name}: official registry must be pinned`);
if (name.startsWith("@")) {
assert.ok(args.some((a) => a === `--${name.split("/")[0]}:registry=https://registry.npmjs.org`), `${name}: scope flag expected`);
} else {
assert.ok(!args.some((a) => a.includes(":registry=")), `${name}: no scope flag for unscoped names`);
}
}
});
test("helper transform fails loudly when the marker is missing", () => {
assert.throws(() => helperPackageNameTransform("atomgit-cli")("const PACKAGE = 'stale';"));
});
test("chooseGlobalBinDir fallback explains the failure and the --target-dir escape hatch", (t) => {
if (process.platform === "win32") {
t.skip("posix fallback path only");
return;
}
const home = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-home-notdir-"));
fs.writeFileSync(path.join(home, ".local"), "regular file");
const unwritable = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-nowrite-"));
fs.chmodSync(unwritable, 0o555);
try {
assert.throws(
() => chooseGlobalBinDir(home, false, [unwritable, path.join(home, ".local", "bin")]),
(error) => /cannot create install directory/.test(error.message) &&
/\.local/.test(error.message) &&
/--target-dir/.test(error.message)
);
} finally {
fs.chmodSync(unwritable, 0o755);
}
});
test("ensureUsableInstallDir reports an unwritable target dir with guidance", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-dir-unusable-"));
if (process.platform === "win32") {
t.skip("chmod read-only bits are advisory on Windows");
return;
}
const dir = path.join(root, "bin");
fs.mkdirSync(dir);
fs.chmodSync(dir, 0o555);
try {
assert.throws(
() => ensureUsableInstallDir(dir),
(error) => /install directory is not usable/.test(error.message) &&
/--target-dir/.test(error.message)
);
} finally {
fs.chmodSync(dir, 0o755);
}
});
test("ensureUsableInstallDir rejects a path occupied by a regular file with guidance", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-dir-notdir-"));
const target = path.join(root, "occupied");
fs.writeFileSync(target, "file");
assert.throws(
() => ensureUsableInstallDir(target),
(error) => /install directory is not usable/.test(error.message) &&
/--target-dir/.test(error.message)
);
});
test("ensureUsableInstallDir accepts and creates a writable target dir", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-dir-ok-"));
const dir = path.join(root, "nested", "bin");
ensureUsableInstallDir(dir);
assert.strictEqual(fs.statSync(dir).isDirectory(), true);
assert.strictEqual(fs.existsSync(path.join(dir, `.gc-install-probe-${process.pid}`)), false);
});
test("formatErrorChain renders nested AggregateError causes with their errno", () => {
const leaf = new Error("EACCES: permission denied, rename 'gc.backup-t1' -> 'gc'");
const inner = new AggregateError([leaf, new Error("restore denied")], "path replacement failed and rollback was incomplete");
const outer = new AggregateError([inner], "installation failed and rollback was incomplete");
const rendered = formatErrorChain(outer);
assert.ok(rendered.includes("installation failed and rollback was incomplete"));
assert.ok(rendered.includes("EACCES: permission denied, rename 'gc.backup-t1' -> 'gc'"));
assert.ok(rendered.includes("restore denied"));
assert.ok(rendered.split("\n").length >= 4);
});
test("install explains that a directory occupies the target and points to --target-dir", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-dir-target-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
fs.writeFileSync(source, "new");
fs.mkdirSync(target);
assert.throws(
() => replacePath(source, target, "dir-target"),
(error) => /refusing non-regular install target/.test(error.message) &&
/is a directory/.test(error.message) &&
/--target-dir/.test(error.message)
);
assert.strictEqual(fs.statSync(target).isDirectory(), true);
});
test("chooseGlobalBinDir returns a writable, existing dir on posix (regardless of /usr/local/bin)", () => {
const home = "/tmp/gc-install-home-" + process.pid;
const dir = chooseGlobalBinDir(home, false);
assert.ok(fs.existsSync(dir), `${dir} should exist`);
const probe = path.join(dir, ".gc-test-probe");
fs.writeFileSync(probe, "");
fs.unlinkSync(probe);
});
test("chooseGlobalBinDir returns the Windows per-user dir on win32 (no FS writability dependency)", () => {
const home = "/u/home";
const dir = chooseGlobalBinDir(home, true);
assert.strictEqual(dir, path.join(home, "AppData", "Local", "gitcode-cli", "bin"));
});
test("completionTarget maps each shell to a standard path", () => {
const home = "/u/home";
assert.strictEqual(completionTarget("bash", home, "gc", {}), path.join(home, ".local", "share", "bash-completion", "completions", "gc"));
assert.strictEqual(completionTarget("zsh", home, "gc", {}), path.join(home, ".zsh", "completions", "_gc"));
assert.strictEqual(completionTarget("fish", home, "gc", {}), path.join(home, ".config", "fish", "completions", "gc.fish"));
assert.strictEqual(completionTarget("powershell", home, "gc", {}), null);
});
test("completionTarget honors XDG and BASH_COMPLETION_USER_DIR", () => {
const home = "/u/home";
const xdg = { XDG_DATA_HOME: "/xdg/data", XDG_CONFIG_HOME: "/xdg/config" };
assert.strictEqual(completionTarget("bash", home, "gc", xdg), path.join("/xdg/data", "bash-completion", "completions", "gc"));
assert.strictEqual(completionTarget("fish", home, "gitcode", xdg), path.join("/xdg/config", "fish", "completions", "gitcode.fish"));
assert.strictEqual(completionTarget("bash", home, "gc", { XDG_DATA_HOME: "rel/data" }),
path.join(home, ".local", "share", "bash-completion", "completions", "gc"));
assert.strictEqual(completionTarget("bash", home, "gc", { ...xdg, BASH_COMPLETION_USER_DIR: "/bcd" }),
path.join("/bcd", "completions", "gc"));
assert.strictEqual(completionTarget("zsh", home, "gc", xdg), path.join(home, ".zsh", "completions", "_gc"));
});
test("installCompletions installs six targets and pins the command name env per pass", () => {
const home = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completions-"));
const calls = [];
const runner = (bin, args, options) => {
calls.push({ bin, args, env: options.env });
return { status: 0, stdout: "# completion script" };
};
const result = installCompletions("/fake/bin/gc", home,
{ env: { GITCODE_CLI_COMMAND_NAME: "gitcode" }, runner });
assert.strictEqual(result.installed.length, 6);
assert.strictEqual(result.skipped.length, 0);
for (const [shell, name] of [
["bash", "gc"], ["bash", "gitcode"], ["zsh", "gc"], ["zsh", "gitcode"],
["fish", "gc"], ["fish", "gitcode"],
]) {
const call = calls.find((c) => c.args[0] === "completion" && c.args[1] === shell && c.env.GITCODE_CLI_COMMAND_NAME === name);
assert.ok(call, `missing ${shell} (${name}) pass`);
}
assert.ok(calls.every((c) => c.env.GC_NO_UPDATE_CHECK === "1"));
});
test("installCompletions emits the zsh fpath notice only when zsh files land", () => {
const home = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completions-"));
const ok = { env: {}, runner: () => ({ status: 0, stdout: "# s" }) };
const result = installCompletions("/fake/gc", home, ok);
assert.strictEqual(result.installed.length, 6);
assert.strictEqual(result.notices.length, 1);
assert.match(result.notices[0], /fpath/);
assert.match(result.notices[0], /compinit/);
const failing = installCompletions("/fake/gc", home, { env: {}, runner: () => ({ status: 1, stdout: "" }) });
assert.strictEqual(failing.installed.length, 0);
assert.strictEqual(failing.notices.length, 0);
});
test("installCompletions skips shadowed command names without touching the other name", () => {
const home = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completions-"));
const result = installCompletions("/fake/gc", home, {
env: {},
runner: () => ({ status: 0, stdout: "# s" }),
shadowedBy: { gc: "/usr/local/bin/gc" },
});
assert.strictEqual(result.installed.length, 3);
assert.ok(result.installed.every((entry) => entry.includes("(gitcode)")));
assert.strictEqual(result.skipped.length, 3);
assert.ok(result.skipped.every((entry) => entry.includes("shadowed by /usr/local/bin/gc")));
});
test("installCompletions attributes each skip reason", () => {
const home = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completions-"));
const okRunner = () => ({ status: 0, stdout: "# s" });
const failing = installCompletions("/fake/gc", home, { env: {}, runner: () => ({ status: 1, stdout: "" }) });
assert.strictEqual(failing.skipped.length, 6);
assert.ok(failing.skipped.every((s) => s.endsWith("completion command failed")));
const bashGc = completionTarget("bash", home, "gc", {});
fs.mkdirSync(path.dirname(bashGc), { recursive: true });
fs.symlinkSync("/elsewhere", bashGc);
const withSymlink = installCompletions("/fake/gc", home, { env: {}, runner: okRunner });
assert.ok(withSymlink.skipped.some((s) => s.includes("bash (gc)") && s.includes("is a symlink")));
assert.strictEqual(withSymlink.installed.length, 5);
const home2 = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completions-"));
fs.writeFileSync(path.join(home2, ".zsh"), "not a directory");
const blocked = installCompletions("/fake/gc", home2, { env: {}, runner: okRunner });
assert.strictEqual(blocked.installed.length, 4);
assert.ok(blocked.skipped.every((s) => s.includes("zsh") && s.includes("not writable")));
});
test("dirOnPath reflects the current PATH delimiter", () => {
assert.strictEqual(dirOnPath("/this/dir/is/not/on/path"), false);
});
test("dirOnPath is case-insensitive and separator-insensitive on Windows", () => {
const env = { PATH: `C:\\Tools\\GitCode\\;C:\\Windows` };
assert.strictEqual(dirOnPath("c:\\tools\\gitcode", env, true), true);
});
test("dirFirstOnPath requires the Windows install directory to have priority", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
assert.strictEqual(dirFirstOnPath(dir, { PATH: `${dir};C:\\Old` }, true), true);
assert.strictEqual(dirFirstOnPath(dir, { PATH: `C:\\Old;${dir}` }, true), false);
});
test("prependWindowsUserPath prepends, de-duplicates, and expands variables for comparison", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" };
const current = `C:\\Old;%LOCALAPPDATA%\\gitcode-cli\\bin\\;${dir.toUpperCase()};C:\\Other`;
assert.strictEqual(prependWindowsUserPath(dir, current, env), `${dir};C:\\Old;C:\\Other`);
assert.strictEqual(prependWindowsUserPath(dir, "", env), dir);
assert.throws(() => prependWindowsUserPath("C:\\bad;path", "", env), /invalid Windows PATH directory/);
});
test("prependWindowsUserPath preserves every non-target PATH segment verbatim", () => {
const dir = "C:\\GitCode\\bin";
const current = " C:\\Keep Spaces \\ ;;;C:\\Other\\;";
assert.strictEqual(prependWindowsUserPath(dir, current, {}), `${dir};${current}`);
});
test("validateWindowsPathDirectory rejects values that cannot be one PATH entry", () => {
assert.doesNotThrow(() => validateWindowsPathDirectory("C:\\GitCode\\bin"));
assert.throws(() => validateWindowsPathDirectory("C:\\safe;C:\\attacker"), /invalid Windows PATH directory/);
assert.throws(() => validateWindowsPathDirectory("C:\\safe\0attacker"), /invalid Windows PATH directory/);
});
test("parseInstallArgs supports target directory and Windows PATH opt-out", () => {
assert.deepStrictEqual(parseInstallArgs([]), { targetDir: "", modifyPath: true });
assert.strictEqual(parseInstallArgs(["--target-dir", "."]).targetDir, path.resolve("."));
assert.deepStrictEqual(parseInstallArgs(["--no-modify-path"]), { targetDir: "", modifyPath: false });
assert.throws(() => parseInstallArgs(["--target-dir"]), /requires a directory value/);
assert.throws(() => parseInstallArgs(["--target-dir", "--no-modify-path"]), /requires a directory value/);
assert.throws(() => parseInstallArgs(["--force"]), /unknown install argument/);
});
test("install help documents target directory and Windows PATH opt-out", () => {
assert.match(installHelp(), /--target-dir <directory>/);
assert.match(installHelp(), /--no-modify-path/);
assert.match(installHelp(), /--target-dir=<directory>/);
assert.match(installHelp(), new RegExp(`${process.platform === "win32" ? "gitcode" : "gc"} install \\[`));
});
test("PowerShell guidance escapes single quotes in target directories", () => {
assert.strictEqual(quotePowerShell("C:\\Users\\O'Brien\\bin;"), "'C:\\Users\\O''Brien\\bin;'");
});
test("persistWindowsUserPath uses one raw-registry PowerShell transaction and a minimal environment", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const calls = [];
const runner = (executable, args, options) => {
calls.push({ executable, args, options });
return {
status: 0,
stdout: '{"changed":true,"kind":"ExpandString","broadcasted":true,"previous":"C:\\\\Python311\\\\Scripts"}',
stderr: "",
};
};
const env = {
SystemRoot: "C:\\Windows",
LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local",
TEMP: "C:\\Temp",
GC_TOKEN: "must-not-leak",
npm_config_registry: "https://untrusted.invalid",
};
const result = persistWindowsUserPath(dir, { env, runner, fileExists: () => true });
assert.deepStrictEqual(result, {
ok: true, changed: true, registryKind: "ExpandString", broadcasted: true,
previousUserPath: "C:\\Python311\\Scripts",
});
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].executable, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
assert.ok(!calls[0].args.join(" ").includes(dir), "target path must not be interpolated into PowerShell code");
const script = calls[0].args.at(-1);
assert.match(script, /DoNotExpandEnvironmentNames/);
assert.match(script, /GetValueKind\('Path'\)/);
assert.match(script, /SetValue\('Path', \$next, \$kind\)/);
assert.match(script, /previous = \[string\]\$current/);
assert.match(script, /Mutex.*Global\\GitCodeCli\.UserPath/);
assert.match(script, /path-mutex-id/);
assert.match(script, /File\]::Move\(\$candidatePath, \$mutexIdPath\)/);
assert.match(script, /MutexSecurity/);
assert.match(script, /SetAccessRuleProtection\(\$true, \$false\)/);
assert.match(script, /LocalSystemSid/);
assert.match(script, /WaitOne\(30000\)/);
assert.match(script, /ReleaseMutex/);
assert.match(script, /SendMessageTimeout/);
assert.match(script, /broadcastStatus/);
assert.match(script, /catch \{ \$broadcasted = \$false \}/);
assert.strictEqual(calls[0].options.env.GITCODE_CLI_TARGET_DIR, dir);
assert.strictEqual(calls[0].options.env.GC_TOKEN, undefined);
assert.strictEqual(calls[0].options.env.npm_config_registry, undefined);
});
test("persistWindowsUserPath reports transaction failures without claiming success", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const env = { SystemRoot: "C:\\Windows" };
const failure = persistWindowsUserPath(dir, {
env,
fileExists: () => true,
runner: () => ({ status: 1, stdout: "", stderr: "registry denied\nextra" }),
});
assert.deepStrictEqual(failure, { ok: false, error: "更新当前用户 PATH失败:registry denied" });
});
test("persistWindowsUserPath reports idempotence from the same registry transaction", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
let calls = 0;
const result = persistWindowsUserPath(dir, {
env: { SystemRoot: "C:\\Windows" },
fileExists: () => true,
runner: () => {
calls += 1;
return { status: 0, stdout: '{"changed":false,"kind":"String","broadcasted":true}', stderr: "" };
},
});
assert.deepStrictEqual(result, {
ok: true, changed: false, registryKind: "String", broadcasted: true, previousUserPath: null,
});
assert.strictEqual(calls, 1);
});
test("persistWindowsUserPath rejects unsafe directories before spawning PowerShell", () => {
let called = false;
const result = persistWindowsUserPath("C:\\safe;C:\\attacker", {
env: { SystemRoot: "C:\\Windows" },
runner: () => { called = true; },
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.invalidDirectory, true);
assert.strictEqual(called, false);
});
test("Windows PATH guidance is explicit and Chinese for current-shell refresh", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const guidance = windowsPathGuidance(
dir,
{ modifyPath: true },
{ ok: true, changed: true },
{ PATH: "C:\\Old" }
);
assert.match(guidance, /已自动将 .* 置于当前用户 PATH 前面/);
assert.match(guidance, /当前 PowerShell\/Windows Terminal 窗口无法由安装器自动刷新 PATH/);
assert.match(guidance, /\$env:Path = 'C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin;' \+ \$env:Path/);
assert.match(guidance, /关闭全部 PowerShell\/Windows Terminal 窗口后重新打开/);
assert.match(guidance, /gitcode version/);
assert.match(guidance, /其他 pip\/npm 安装入口不会被自动删除/);
assert.match(guidance, /gitcode doctor install/);
});
test("Windows PATH guidance explains opt-out and persistence failure in Chinese", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const optOut = windowsPathGuidance(dir, { modifyPath: false }, { ok: true, changed: false }, { PATH: "" });
assert.match(optOut, /已按 --no-modify-path 跳过持久 PATH 修改/);
assert.match(optOut, /编辑账户的环境变量/);
assert.doesNotMatch(optOut, /SetEnvironmentVariable/);
const failure = windowsPathGuidance(dir, { modifyPath: true }, { ok: false, error: "拒绝访问" }, { PATH: "" });
assert.match(failure, /警告:未能自动更新当前用户 PATH:拒绝访问/);
assert.match(failure, /编辑账户的环境变量/);
assert.doesNotMatch(failure, /SetEnvironmentVariable/);
});
test("Windows PATH guidance reports broadcast failure without claiming full propagation", () => {
const dir = "C:\\Users\\u\\AppData\\Local\\gitcode-cli\\bin";
const guidance = windowsPathGuidance(
dir,
{ modifyPath: true },
{ ok: true, changed: true, broadcasted: false },
{ PATH: "C:\\Old" }
);
assert.match(guidance, /持久 User PATH 已写入/);
assert.match(guidance, /未能通知桌面环境/);
assert.match(guidance, /注销并重新登录 Windows/);
});
test("Windows PATH guidance never emits PATH commands for an unsafe directory", () => {
const guidance = windowsPathGuidance(
"C:\\safe;C:\\attacker",
{ modifyPath: true },
{ ok: false, error: "invalid Windows PATH directory", invalidDirectory: true },
{ PATH: "C:\\Old" }
);
assert.match(guidance, /未生成任何 PATH 修改命令/);
assert.doesNotMatch(guidance, /SetEnvironmentVariable/);
assert.doesNotMatch(guidance, /\$env:Path/);
assert.match(guidance, /gitcode\.exe' version/);
});
test("windowsPathShadowing classifies providers by old User PATH membership", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-win-shadow-fn-"));
const early = path.join(root, "python");
const install = path.join(root, "install");
fs.mkdirSync(early);
fs.mkdirSync(install);
fs.writeFileSync(path.join(early, "gitcode.exe"), "");
fs.writeFileSync(path.join(install, "gc.exe"), "");
const env = { PATH: `${early};${install}` };
const system = windowsPathShadowing(install, env, install);
assert.strictEqual(system.length, 1);
assert.strictEqual(system[0].name, "gitcode");
assert.strictEqual(system[0].provider, path.join(early, "gitcode.exe"));
assert.strictEqual(system[0].scope, "system");
const user = windowsPathShadowing(install, env, early);
assert.strictEqual(user[0].scope, "user");
const expanded = windowsPathShadowing(install, { PATH: `${early};${install}`, EARLY: early }, "%EARLY%");
assert.strictEqual(expanded[0].scope, "user");
const unknown = windowsPathShadowing(install, env, null);
assert.strictEqual(unknown[0].scope, "unknown");
const clean = windowsPathShadowing(install, { PATH: `${install}` }, null);
assert.deepStrictEqual(clean, []);
});
test("Windows PATH guidance distinguishes reopen-fixable from persistent shadowing", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-win-shadow-guide-"));
const early = path.join(root, "python");
const install = path.join(root, "install");
fs.mkdirSync(early);
fs.mkdirSync(install);
fs.writeFileSync(path.join(early, "gitcode.exe"), "");
fs.writeFileSync(path.join(install, "gc.exe"), "");
const env = { PATH: `${early};${install}` };
const systemScope = windowsPathGuidance(
install, { modifyPath: true }, { ok: true, changed: true, previousUserPath: install }, env
);
assert.match(systemScope, /重开窗口也无法解决/);
assert.match(systemScope, /系统 PATH/);
assert.match(systemScope, /gitcode doctor install/);
const userScope = windowsPathGuidance(
install, { modifyPath: true }, { ok: true, changed: true, previousUserPath: early }, env
);
assert.match(userScope, /重新打开 PowerShell\/Windows Terminal 窗口后本安装将优先生效/);
const unknownScope = windowsPathGuidance(
install, { modifyPath: true }, { ok: true, changed: true }, env
);
assert.match(unknownScope, /若该提供者来自系统 PATH/);
const clean = windowsPathGuidance(
install, { modifyPath: true }, { ok: true, changed: true, previousUserPath: install }, { PATH: `${install}` }
);
assert.doesNotMatch(clean, /先于本安装目录/);
});
test("install rollback restores only files touched by the current transaction", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-transaction-"));
const source = path.join(root, "source");
const first = path.join(root, "gc");
const second = path.join(root, "gitcode");
const untouched = path.join(root, "helper");
fs.writeFileSync(source, "new");
fs.writeFileSync(first, "first-current");
fs.writeFileSync(second, "second-current");
fs.writeFileSync(untouched, "untouched-current");
fs.writeFileSync(`${untouched}.previous`, "stale-previous");
const transaction = [replacePath(source, first, "test"), replacePath(source, second, "test")];
rollbackTransaction(transaction);
assert.strictEqual(fs.readFileSync(first, "utf8"), "first-current");
assert.strictEqual(fs.readFileSync(second, "utf8"), "second-current");
assert.strictEqual(fs.readFileSync(untouched, "utf8"), "untouched-current");
});
test("install commit removes transaction-scoped backups", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-commit-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
const record = replacePath(source, target, "commit");
commitTransaction([record]);
assert.strictEqual(fs.readFileSync(target, "utf8"), "new");
assert.strictEqual(fs.existsSync(record.backup), false);
});
test("replacePath applies the helper transform at the wiring level", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-helper-wiring-"));
const copied = path.join(root, "gitcode-update-helper.js");
const record = replacePath(
path.join(__dirname, "..", "lib", "bootstrap-update-helper.js"),
copied,
"helper-wiring",
{ transform: helperPackageNameTransform(require("../package.json").name) }
);
commitTransaction([record]);
const helper = require(copied);
assert.ok(helper.withNpmIsolation(["exec", "--", "gitcode", "install"], "u.npmrc", "g.npmrc")
.includes("--registry=https://registry.npmjs.org"));
});
function createFileSymlinkOrSkip(t, target, link) {
try {
fs.symlinkSync(target, link, "file");
} catch (error) {
if (process.platform === "win32" && ["EPERM", "EACCES"].includes(error.code)) {
t.skip("creating symlinks requires Windows Developer Mode or elevated privileges");
return false;
}
throw error;
}
return true;
}
test("install transaction migrates a same-directory gitcode alias and restores it on rollback", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const alias = path.join(root, "gitcode");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
if (!createFileSymlinkOrSkip(t, "gc", alias)) return;
const transaction = [
replacePath(source, target, "alias-rollback"),
replacePath(source, alias, "alias-rollback", { allowedSymlinkTarget: target }),
];
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), false);
assert.strictEqual(fs.readFileSync(alias, "utf8"), "new");
rollbackTransaction(transaction);
assert.strictEqual(fs.readFileSync(target, "utf8"), "old");
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readlinkSync(alias), "gc");
assert.strictEqual(fs.readFileSync(alias, "utf8"), "old");
for (const record of transaction) assert.strictEqual(fs.existsSync(record.backup), false);
});
test("install commit removes the backup of a migrated same-directory alias", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-commit-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const alias = path.join(root, "gitcode");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
if (!createFileSymlinkOrSkip(t, "gc", alias)) return;
const record = replacePath(source, alias, "alias-commit", { allowedSymlinkTarget: target });
commitTransaction([record]);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), false);
assert.strictEqual(fs.readFileSync(alias, "utf8"), "new");
assert.strictEqual(fs.existsSync(record.backup), false);
});
test("install rejects aliases that point anywhere except the sibling gc binary", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-reject-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const other = path.join(root, "other");
const alias = path.join(root, "gitcode");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
fs.writeFileSync(other, "unrelated");
if (!createFileSymlinkOrSkip(t, "other", alias)) return;
assert.throws(
() => replacePath(source, alias, "alias-reject", { allowedSymlinkTarget: target }),
/refusing non-regular install target/
);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readlinkSync(alias), "other");
assert.strictEqual(fs.readFileSync(other, "utf8"), "unrelated");
});
test("install rejects a lexical sibling alias that resolves through an external directory symlink", (t) => {
if (process.platform === "win32") {
t.skip("nested symlink traversal is a POSIX migration boundary");
return;
}
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-traversal-"));
const external = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-external-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const hop = path.join(root, "hop");
const alias = path.join(root, "gitcode");
const externalInner = path.join(external, "inner");
const externalTarget = path.join(external, "gc");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "allowed");
fs.mkdirSync(externalInner);
fs.writeFileSync(externalTarget, "external");
fs.symlinkSync(externalInner, hop, "dir");
fs.symlinkSync("hop/../gc", alias, "file");
assert.strictEqual(fs.readFileSync(alias, "utf8"), "external");
assert.throws(
() => replacePath(source, alias, "alias-traversal", { allowedSymlinkTarget: target }),
/refusing non-regular install target/
);
assert.strictEqual(fs.readlinkSync(alias), "hop/../gc");
assert.strictEqual(fs.readFileSync(externalTarget, "utf8"), "external");
assert.strictEqual(fs.readFileSync(target, "utf8"), "allowed");
});
test("install still rejects a symlink at the primary gc target", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-primary-reject-"));
const source = path.join(root, "source");
const other = path.join(root, "other");
const target = path.join(root, "gc");
fs.writeFileSync(source, "new");
fs.writeFileSync(other, "old");
if (!createFileSymlinkOrSkip(t, "other", target)) return;
assert.throws(() => replacePath(source, target, "primary-reject"), /refusing non-regular install target/);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(other, "utf8"), "old");
});
test("install adopts a gitcode symlink left by a classic npm-global install and restores it on rollback", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-npm-global-"));
const packageBin = path.join(root, "lib", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(packageBin), { recursive: true });
fs.writeFileSync(packageBin, "old");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, "../lib/node_modules/@gitcode-cli/cli/bin/gc.js", alias)) return;
const record = replacePath(source, alias, "npm-global-alias");
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), false);
assert.strictEqual(fs.readFileSync(alias, "utf8"), "new");
assert.strictEqual(fs.lstatSync(record.backup).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(record.backup, "utf8"), "old");
rollbackTransaction([record]);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readlinkSync(alias).split(path.sep).join("/"), "../lib/node_modules/@gitcode-cli/cli/bin/gc.js");
assert.strictEqual(fs.readFileSync(alias, "utf8"), "old");
assert.strictEqual(fs.existsSync(record.backup), false);
});
test("install adopts npm-global symlinks for every own npm coordinate", (t) => {
for (const coordinate of ["@atomgit-cli/cli", "@gitcode-cli/cli", "atomgit-cli"]) {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-npm-coord-"));
const packageBin = path.join(root, "node_modules", ...coordinate.split("/"), "bin", "gc.js");
fs.mkdirSync(path.dirname(packageBin), { recursive: true });
fs.writeFileSync(packageBin, "old");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, packageBin), alias)) return;
const record = replacePath(source, alias, `npm-coord-${coordinate.replace(/\//g, "-")}`);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), false, coordinate);
assert.strictEqual(fs.readFileSync(alias, "utf8"), "new", coordinate);
commitTransaction([record]);
assert.strictEqual(fs.existsSync(record.backup), false, coordinate);
}
});
test("install adopts an npm-global symlink at the primary gc target", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-npm-primary-"));
const packageBin = path.join(root, "node_modules", "atomgit-cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(packageBin), { recursive: true });
fs.writeFileSync(packageBin, "old");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, packageBin), target)) return;
const record = replacePath(source, target, "npm-global-primary");
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), false);
assert.strictEqual(fs.readFileSync(target, "utf8"), "new");
commitTransaction([record]);
});
test("install adopts a broken npm-global symlink into our own package tree", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-npm-broken-"));
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, "../lib/node_modules/@gitcode-cli/cli/bin/gc.js", alias)) return;
const record = replacePath(source, alias, "npm-global-broken");
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), false);
assert.strictEqual(fs.readFileSync(alias, "utf8"), "new");
commitTransaction([record]);
});
test("install rejects a symlink into the third-party gitcode-cli package with uninstall guidance", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-third-party-"));
const packageBin = path.join(root, "lib", "node_modules", "gitcode-cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(packageBin), { recursive: true });
fs.writeFileSync(packageBin, "unrelated");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, "../lib/node_modules/gitcode-cli/bin/gc.js", alias)) return;
assert.throws(
() => replacePath(source, alias, "third-party-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/node_modules\/gitcode-cli/.test(message) &&
/npm uninstall -g gitcode-cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(packageBin, "utf8"), "unrelated");
});
test("install rejects a foreign symlink with the link target and --target-dir guidance", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-foreign-"));
const source = path.join(root, "source");
const other = path.join(root, "other");
const alias = path.join(root, "gitcode");
fs.writeFileSync(source, "new");
fs.writeFileSync(other, "unrelated");
if (!createFileSymlinkOrSkip(t, "other", alias)) return;
assert.throws(
() => replacePath(source, alias, "foreign-reject"),
(error) => /refusing non-regular install target/.test(error.message) &&
/symlink -> other/.test(error.message) &&
/--target-dir/.test(error.message)
);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(other, "utf8"), "unrelated");
});
test("install reports an actionable refusal for a symlink loop at the primary target", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-loop-"));
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, "gitcode", target)) return;
if (!createFileSymlinkOrSkip(t, "gc", path.join(binDir, "gitcode"))) return;
assert.throws(
() => replacePath(source, target, "loop-reject"),
(error) => /refusing non-regular install target/.test(error.message) &&
!/ELOOP/.test(error.message)
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("install reports an actionable refusal for a symlink loop at the alias target", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-alias-loop-"));
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const realGc = path.join(binDir, "gc");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
fs.writeFileSync(realGc, "binary");
if (!createFileSymlinkOrSkip(t, "gitcode", alias)) return;
assert.throws(
() => replacePath(source, alias, "alias-loop-reject", { allowedSymlinkTarget: realGc }),
(error) => /refusing non-regular install target/.test(error.message) &&
!/ELOOP/.test(error.message)
);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(realGc, "utf8"), "binary");
});
test("install refuses symlinks inside the nested dependency tree of another package", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-nested-"));
const packageBin = path.join(root, "project", "node_modules", "pkg-a", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(packageBin), { recursive: true });
fs.writeFileSync(packageBin, "old");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, packageBin), alias)) return;
assert.throws(() => replacePath(source, alias, "nested-reject"), /refusing non-regular install target/);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(packageBin, "utf8"), "old");
});
test("install judges npm-global adoption by the resolved path, not the link text", (t) => {
if (process.platform === "win32") {
t.skip("directory symlinks require Windows Developer Mode or elevated privileges");
return;
}
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-authority-"));
const external = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-authority-external-"));
fs.mkdirSync(path.join(root, "lib"));
fs.mkdirSync(path.join(external, "libs", "@gitcode-cli", "cli", "bin"), { recursive: true });
const externalBin = path.join(external, "libs", "@gitcode-cli", "cli", "bin", "gc.js");
fs.writeFileSync(externalBin, "external");
fs.symlinkSync(path.join(external, "libs"), path.join(root, "lib", "node_modules"), "dir");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, "../lib/node_modules/@gitcode-cli/cli/bin/gc.js", alias)) return;
assert.throws(() => replacePath(source, alias, "authority-reject"), /refusing non-regular install target/);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(externalBin, "utf8"), "external");
});
test("rollback continues restoring remaining paths after one restore fails", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-rollback-failure-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const blocked = path.join(root, "blocked-directory");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
fs.mkdirSync(blocked);
const good = replacePath(source, target, "failure-test");
assert.throws(
() => rollbackTransaction([good, { dst: blocked, backup: `${blocked}.backup`, hadOriginal: false }]),
(error) => error instanceof AggregateError && error.errors.length === 1
);
assert.strictEqual(fs.readFileSync(target, "utf8"), "old");
});
test("rollback reports a missing backup and preserves the current target", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-missing-backup-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
const record = replacePath(source, target, "missing-backup");
fs.unlinkSync(record.backup);
assert.throws(
() => rollbackTransaction([record]),
(error) => error instanceof AggregateError &&
error.errors.length === 1 &&
error.errors[0].code === "EROLLBACK"
);
assert.strictEqual(fs.readFileSync(target, "utf8"), "new");
});
test("replacePath reports both the replacement and internal restore failures", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-internal-restore-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const backup = `${target}.backup-internal-restore`;
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
const originalRenameSync = fs.renameSync;
fs.renameSync = (src, dst) => {
const error = new Error(src === backup ? "restore denied" : "replacement denied");
error.code = "EIO";
throw error;
};
try {
assert.throws(
() => replacePath(source, target, "internal-restore"),
(error) => error instanceof AggregateError &&
error.errors.length === 2 &&
error.errors[0].message === "replacement denied" &&
error.errors[1].message === "restore denied"
);
} finally {
fs.renameSync = originalRenameSync;
}
assert.strictEqual(fs.readFileSync(target, "utf8"), "old");
assert.strictEqual(fs.readFileSync(backup, "utf8"), "old");
});
test("replacePath reports a backup that disappears before internal restore", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-lost-backup-"));
const source = path.join(root, "source");
const target = path.join(root, "gc");
const backup = `${target}.backup-lost-backup`;
fs.writeFileSync(source, "new");
fs.writeFileSync(target, "old");
const originalRenameSync = fs.renameSync;
let replacementAttempts = 0;
fs.renameSync = (src, dst) => {
if (src.startsWith(`${target}.tmp-`)) {
replacementAttempts += 1;
const error = new Error(replacementAttempts === 1 ? "replace collision" : "replacement failed");
error.code = replacementAttempts === 1 ? "EPERM" : "EIO";
if (replacementAttempts === 2) fs.unlinkSync(backup);
throw error;
}
return originalRenameSync(src, dst);
};
try {
assert.throws(
() => replacePath(source, target, "lost-backup"),
(error) => error instanceof AggregateError &&
error.errors.length === 2 &&
error.errors[0].message === "replacement failed" &&
error.errors[1].code === "EROLLBACK"
);
} finally {
fs.renameSync = originalRenameSync;
}
assert.strictEqual(fs.existsSync(target), false);
assert.strictEqual(fs.existsSync(backup), false);
});
test("sweepTransactionLeftovers removes stale regular leftovers only", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-sweep-"));
const now = Date.now();
const stale = new Date(now - 25 * 60 * 60 * 1000);
const files = {
"gc.backup-old-txn": { content: "stale", mtime: stale, removed: true },
"gitcode.tmp-111-abc": { content: "stale", mtime: stale, removed: true },
"gitcode-update-helper.js.backup-old": { content: "stale", mtime: stale, removed: true },
".gc-install-probe-999": { content: "stale", mtime: stale, removed: true },
".gitcode-install.json.tmp-old-1-abc": { content: "stale", mtime: stale, removed: true },
".gitcode-install.json.tmp-fresh": { content: "fresh", mtime: new Date(now), removed: false },
"gc.tmp-222-fresh": { content: "fresh", mtime: new Date(now), removed: false },
"gc": { content: "binary", mtime: stale, removed: false },
"gitcode": { content: "binary", mtime: stale, removed: false },
".gitcode-install.json": { content: "{}", mtime: stale, removed: false },
"unrelated.backup-not-ours": { content: "x", mtime: stale, removed: false },
};
for (const [name, spec] of Object.entries(files)) {
fs.writeFileSync(path.join(root, name), spec.content);
fs.utimesSync(path.join(root, name), spec.mtime, spec.mtime);
}
if (!createFileSymlinkOrSkip(t, "gc", path.join(root, "gitcode.backup-migrated"))) return;
fs.utimesSync(path.join(root, "gitcode.backup-migrated"), stale, stale);
const removed = sweepTransactionLeftovers(root, now);
let expectedRemovals = 0;
for (const [name, spec] of Object.entries(files)) {
assert.strictEqual(fs.existsSync(path.join(root, name)), !spec.removed, name);
if (spec.removed) expectedRemovals += 1;
}
assert.strictEqual(fs.lstatSync(path.join(root, "gitcode.backup-migrated")).isSymbolicLink(), true);
assert.strictEqual(removed, expectedRemovals);
});
test("isTransactionLeftoverName matches only installer transaction artifacts", () => {
for (const name of [
"gc.backup-1-abc", "gc.tmp-1-abc",
"gc.exe.backup-1-abc", "gc.exe.tmp-1-abc",
"gitcode.backup-1-abc", "gitcode.tmp-1-abc",
"gitcode.exe.backup-1-abc", "gitcode.exe.tmp-1-abc",
"gitcode-update-helper.js.backup-1-abc", "gitcode-update-helper.js.tmp-1-abc",
".gitcode-install.json.tmp-1-abc", ".gitcode-install.json.backup-1-abc",
".gc-install-probe-123", ".gc-write-probe",
]) {
assert.strictEqual(isTransactionLeftoverName(name), true, name);
}
for (const name of ["gc", "gitcode", "gc.exe", "gitcode.exe", "gitcode-update-helper.js", ".gitcode-install.json", "other.backup-x", "gcbackup-1"]) {
assert.strictEqual(isTransactionLeftoverName(name), false, name);
}
});
test("foreignChannelHint recognizes pip console scripts and ignores binaries", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-channel-hint-"));
const script = path.join(root, "gitcode");
fs.writeFileSync(script, "#!/usr/bin/env python3\nimport gc_cli\n");
assert.match(foreignChannelHint(script), /pip uninstall gitcode-cli/);
const versioned = path.join(root, "gitcode3");
fs.writeFileSync(versioned, "#!/usr/local/bin/python3.11\nimport gc_cli\n");
assert.match(foreignChannelHint(versioned), /pip uninstall gitcode-cli/);
const fakeComment = path.join(root, "fake");
fs.writeFileSync(fakeComment, "#!/bin/sh # python wrapper\nexec something\n");
assert.strictEqual(foreignChannelHint(fakeComment), "");
const binary = path.join(root, "gc");
fs.writeFileSync(binary, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]));
assert.strictEqual(foreignChannelHint(binary), "");
const missing = path.join(root, "absent");
assert.strictEqual(foreignChannelHint(missing), "");
});
test("foreignChannelHint accepts interpreter arguments and Windows PE shims", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-channel-hint-args-"));
for (const shebang of [
"#!/usr/bin/env python3 -E",
"#!/usr/bin/env -S python3",
"#!/usr/bin/env -S python3 -u -E",
"#!/usr/local/bin/python3.11 -u",
]) {
const script = path.join(root, shebang.replace(/[^a-z0-9]/gi, "_").slice(0, 40));
fs.writeFileSync(script, `${shebang}\nimport gc_cli\n`);
assert.match(foreignChannelHint(script), /pip uninstall gitcode-cli/, shebang);
}
const odd = path.join(root, "odd");
fs.writeFileSync(odd, "#!/usr/bin/env python3 /opt/tool.py\n");
assert.strictEqual(foreignChannelHint(odd), "");
const shim = path.join(root, "gc.exe");
fs.writeFileSync(shim, Buffer.concat([
Buffer.from([0x4d, 0x5a, 0x90, 0x00]),
Buffer.alloc(150 * 1024, 0x00),
Buffer.from('#!"C:\\Python311\\python.exe"\r\n', "utf8"),
Buffer.from("PK\x03\x04zip-payload", "utf8"),
]));
assert.match(foreignChannelHint(shim, true), /pip uninstall gitcode-cli/);
const plain = path.join(root, "plain.exe");
fs.writeFileSync(plain, Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(2048, 1)]));
assert.strictEqual(foreignChannelHint(plain, true), "");
const far = path.join(root, "far.exe");
fs.writeFileSync(far, Buffer.concat([
Buffer.from([0x4d, 0x5a]),
Buffer.alloc(1 << 20, 0x00),
Buffer.from('#!"C:\\Python311\\python.exe"\r\n', "utf8"),
]));
assert.strictEqual(foreignChannelHint(far, true), "");
});
test("acquireInstallLock serializes installs and reclaims stale locks", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-install-lock-"));
const lock = acquireInstallLock(root);
assert.strictEqual(fs.existsSync(lock.path), true);
assert.strictEqual(typeof lock.fd, "number");
assert.strictEqual(isTransactionLeftoverName(path.basename(lock.path)), false);
assert.throws(() => acquireInstallLock(root), /another gc install appears to be running/);
releaseInstallLock(lock);
assert.strictEqual(fs.existsSync(lock), false);
const again = acquireInstallLock(root);
releaseInstallLock(again);
const stale = acquireInstallLock(root);
const old = new Date(Date.now() - 11 * 60 * 1000);
fs.utimesSync(stale.path, old, old);
fs.closeSync(stale.fd);
const reclaimed = acquireInstallLock(root);
assert.strictEqual(fs.readFileSync(reclaimed.path, "utf8").trim().split(" ")[0], String(process.pid));
releaseInstallLock(reclaimed);
fs.writeFileSync(path.join(root, ".gc-install-lock"), "");
fs.utimesSync(path.join(root, ".gc-install-lock"), old, old);
const originalRenameSync = fs.renameSync;
try {
fs.renameSync = () => {
const error = new Error("ENOENT: no such file or directory");
error.code = "ENOENT";
throw error;
};
assert.throws(() => acquireInstallLock(root), /another gc install appears to be running/);
} finally {
fs.renameSync = originalRenameSync;
}
const ours = acquireInstallLock(root);
fs.writeFileSync(path.join(root, ".gc-install-lock"), "999999 2000-01-01T00:00:00.000Z\n");
releaseInstallLock(ours);
assert.strictEqual(fs.existsSync(path.join(root, ".gc-install-lock")), true);
fs.unlinkSync(path.join(root, ".gc-install-lock"));
});
test("writeCompletionFile never writes through a symlink and replaces atomically", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-completion-"));
const target = path.join(root, "_gc");
fs.writeFileSync(target, "old");
assert.deepStrictEqual(writeCompletionFile(target, "new"), { skipped: false });
assert.strictEqual(fs.readFileSync(target, "utf8"), "new");
const victim = path.join(root, "victim");
fs.writeFileSync(victim, "precious");
fs.unlinkSync(target);
if (!createFileSymlinkOrSkip(t, "victim", target)) return;
assert.deepStrictEqual(writeCompletionFile(target, "new"), { skipped: true });
assert.strictEqual(fs.readFileSync(victim, "utf8"), "precious");
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("parseInstallArgs accepts --target-dir= forms and dash-prefixed names", () => {
assert.deepStrictEqual(parseInstallArgs(["--target-dir=/opt/gc"]), {
targetDir: path.resolve("/opt/gc"),
modifyPath: true,
});
const dashed = parseInstallArgs(["--target-dir", "-weird"]);
assert.strictEqual(dashed.targetDir, path.resolve("-weird"));
assert.strictEqual(parseInstallArgs(["--target-dir", "./-weird"]).targetDir, path.resolve("./-weird"));
assert.throws(() => parseInstallArgs(["--target-dir", "--no-modify-path"]), /\.\/-name or --target-dir=-name/);
assert.throws(() => parseInstallArgs(["--target-dir", "--unknown-flag"]), /requires a directory value/);
assert.throws(() => parseInstallArgs(["--target-dir"]), /requires a directory value/);
assert.throws(() => parseInstallArgs(["--target-dir="]), /requires a directory value/);
assert.throws(() => parseInstallArgs(["--target-dirx"]), /unknown install argument/);
});
test("install gives Homebrew-specific guidance for a brew-owned symlink", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-brew-link-"));
const brewBin = path.join(root, "opt", "homebrew", "Cellar", "gc", "0.14.0", "bin", "gc");
fs.mkdirSync(path.dirname(brewBin), { recursive: true });
fs.writeFileSync(brewBin, "brew-binary");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, brewBin), alias)) return;
assert.throws(
() => replacePath(source, alias, "brew-reject"),
(error) => /refusing non-regular install target/.test(error.message) &&
/Homebrew/.test(error.message) &&
/brew uninstall gc/.test(error.message)
);
assert.strictEqual(fs.lstatSync(alias).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(brewBin, "utf8"), "brew-binary");
});
test("install gives pipx-specific guidance for a pipx-owned symlink", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pipx-link-"));
const pipxBin = path.join(root, ".local", "pipx", "venvs", "gitcode-cli", "bin", "gitcode");
fs.mkdirSync(path.dirname(pipxBin), { recursive: true });
fs.writeFileSync(pipxBin, "pipx-shim");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, pipxBin), alias)) return;
assert.throws(
() => replacePath(source, alias, "pipx-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/pipx uninstall gitcode-cli/.test(message);
}
);
});
test("install keeps the generic guidance for a non-pipx directory named pipx", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pipx-lookalike-"));
const fakeBin = path.join(root, "pipx", "mytool", "bin", "gitcode");
fs.mkdirSync(path.dirname(fakeBin), { recursive: true });
fs.writeFileSync(fakeBin, "unrelated");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gitcode");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, fakeBin), alias)) return;
assert.throws(
() => replacePath(source, alias, "pipx-lookalike-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/--target-dir/.test(message) &&
!/pipx uninstall/.test(message);
}
);
});
test("install refuses a pnpm-global symlink instead of adopting it", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pnpm-link-"));
const pnpmBin = path.join(root, ".local", "share", "pnpm", "global", "5", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(pnpmBin), { recursive: true });
fs.writeFileSync(pnpmBin, "pnpm-wrapper");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, pnpmBin), target)) return;
assert.throws(
() => replacePath(source, target, "pnpm-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/pnpm remove -g @gitcode-cli\/cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
assert.strictEqual(fs.readFileSync(pnpmBin, "utf8"), "pnpm-wrapper");
});
test("install refuses a pnpm symlink with an absolute target or a broken link", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pnpm-abs-"));
const pnpmBin = path.join(root, "pnpm", "global", "5", "node_modules", "atomgit-cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(pnpmBin), { recursive: true });
fs.writeFileSync(pnpmBin, "pnpm-wrapper");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
fs.writeFileSync(source, "new");
const absolute = path.join(binDir, "gc");
if (!createFileSymlinkOrSkip(t, pnpmBin, absolute)) return;
assert.throws(
() => replacePath(source, absolute, "pnpm-abs-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/pnpm remove -g atomgit-cli/.test(message);
}
);
const broken = path.join(binDir, "gitcode");
if (!createFileSymlinkOrSkip(t, path.join(root, "pnpm", "global", "5", "node_modules", "@gitcode-cli", "cli", "bin", "gone.js"), broken)) return;
assert.throws(
() => replacePath(source, broken, "pnpm-broken-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/pnpm remove -g/.test(message);
}
);
assert.strictEqual(fs.lstatSync(broken).isSymbolicLink(), true);
});
test("install refuses to replace a pip console script instead of warn-and-destroy", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pip-script-"));
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
fs.writeFileSync(source, "new");
const target = path.join(binDir, "gc");
fs.writeFileSync(target, "#!/usr/bin/env python3\nimport gc_cli\n");
assert.throws(
() => replacePath(source, target, "pip-reject"),
(error) => /refusing to replace foreign install target/.test(error.message) &&
/pip uninstall gitcode-cli/.test(error.message) &&
/--target-dir/.test(error.message)
);
assert.strictEqual(fs.readFileSync(target, "utf8"), "#!/usr/bin/env python3\nimport gc_cli\n");
const plain = path.join(binDir, "gitcode");
fs.writeFileSync(plain, "old-binary");
const record = replacePath(source, plain, "pip-plain");
assert.strictEqual(fs.readFileSync(plain, "utf8"), "new");
fs.unlinkSync(record.backup);
});
test("chooseGlobalBinDir avoids the Homebrew domain when the layout is present", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-brew-skip-"));
const fakeCellar = path.join(root, "Cellar");
fs.mkdirSync(fakeCellar);
const fallback = path.join(root, "local-bin");
assert.strictEqual(
chooseGlobalBinDir("/home/u", false, ["/usr/local/bin", fallback], fakeCellar),
fallback
);
assert.strictEqual(
chooseGlobalBinDir("/home/u", false, [fallback], path.join(root, "absent")),
fallback
);
});
test("install gives uv-specific guidance for a uv tool symlink", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-uv-link-"));
const uvBin = path.join(root, ".local", "share", "uv", "tools", "gitcode-cli", "bin", "gc");
fs.mkdirSync(path.dirname(uvBin), { recursive: true });
fs.writeFileSync(uvBin, "uv-shim");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, uvBin), target)) return;
assert.throws(
() => replacePath(source, target, "uv-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/uv tool uninstall gitcode-cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("install gives yarn-specific guidance for a yarn global symlink", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-yarn-link-"));
const yarnBin = path.join(root, ".config", "yarn", "global", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(yarnBin), { recursive: true });
fs.writeFileSync(yarnBin, "yarn-wrapper");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, yarnBin), target)) return;
assert.throws(
() => replacePath(source, target, "yarn-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/yarn global remove @gitcode-cli\/cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("firstProviderOnPath resolves the earliest provider and skips broken links", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-provider-path-"));
const early = path.join(root, "early");
const late = path.join(root, "late");
fs.mkdirSync(early);
fs.mkdirSync(late);
fs.writeFileSync(path.join(early, "gc"), "binary");
fs.writeFileSync(path.join(late, "gc"), "binary");
fs.writeFileSync(path.join(late, "gitcode"), "binary");
const env = { PATH: [early, late].join(path.delimiter) };
assert.strictEqual(firstProviderOnPath("gc", env), path.join(early, "gc"));
assert.strictEqual(firstProviderOnPath("gitcode", env), path.join(late, "gitcode"));
assert.strictEqual(firstProviderOnPath("missing", env), "");
if (process.platform !== "win32") {
const broken = path.join(root, "broken");
fs.mkdirSync(broken);
fs.symlinkSync("nowhere", path.join(broken, "gitcode"), "file");
const envBroken = { PATH: [broken, late].join(path.delimiter) };
assert.strictEqual(firstProviderOnPath("gitcode", envBroken), path.join(late, "gitcode"));
}
});
test("install refuses a yarn symlink from the Windows default global layout", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-yarn-win-"));
const yarnBin = path.join(root, "AppData", "Local", "Yarn", "Data", "global", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(yarnBin), { recursive: true });
fs.writeFileSync(yarnBin, "yarn-wrapper");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, yarnBin), target)) return;
assert.throws(
() => replacePath(source, target, "yarn-win-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/yarn global remove @gitcode-cli\/cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("install refuses a bun global symlink with bun-specific guidance", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-bun-link-"));
const bunBin = path.join(root, ".bun", "install", "global", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(bunBin), { recursive: true });
fs.writeFileSync(bunBin, "bun-wrapper");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, bunBin), target)) return;
assert.throws(
() => replacePath(source, target, "bun-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/bun remove -g @gitcode-cli\/cli/.test(message);
}
);
assert.strictEqual(fs.lstatSync(target).isSymbolicLink(), true);
});
test("a project-level pnpm .bin symlink gets project-scoped removal guidance", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-pnpm-proj-"));
const storeBin = path.join(root, "proj", "node_modules", ".pnpm", "@gitcode-cli+cli@1.0.0", "node_modules", "@gitcode-cli", "cli", "bin", "gc.js");
fs.mkdirSync(path.dirname(storeBin), { recursive: true });
fs.writeFileSync(storeBin, "pnpm-wrapper");
const binDir = path.join(root, "proj", "node_modules", ".bin");
fs.mkdirSync(binDir, { recursive: true });
const source = path.join(root, "source");
const target = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, storeBin), target)) return;
assert.throws(
() => replacePath(source, target, "pnpm-proj-reject"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/pnpm remove @gitcode-cli\/cli in the owning project/.test(message) &&
!/pnpm remove -g/.test(message);
}
);
});
test("foreignChannelHint routes uv and pipx console scripts to their own guidance", () => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-hint-channels-"));
const uvScript = path.join(root, "gc");
fs.writeFileSync(uvScript, "#!/home/u/.local/share/uv/tools/gitcode-cli/bin/python\nimport gc_cli\n");
assert.match(foreignChannelHint(uvScript), /uv tool uninstall gitcode-cli/);
const pipxScript = path.join(root, "gitcode");
fs.writeFileSync(pipxScript, "#!/home/u/.local/pipx/venvs/gitcode-cli/bin/python3.11\nimport gc_cli\n");
assert.match(foreignChannelHint(pipxScript), /pipx uninstall gitcode-cli/);
const pipScript = path.join(root, "plain");
fs.writeFileSync(pipScript, "#!/usr/bin/env python3\nimport gc_cli\n");
assert.match(foreignChannelHint(pipScript), /pip uninstall gitcode-cli/);
});
test("a dangling symlink at the lock path fails with explicit guidance", (t) => {
if (process.platform === "win32") {
t.skip("POSIX symlink-at-lock semantics only");
return;
}
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-lock-symlink-"));
fs.symlinkSync("nowhere", path.join(root, ".gc-install-lock"), "file");
assert.throws(() => acquireInstallLock(root), /symbolic link; remove it manually/);
});
test("completionTarget produces both gc and gitcode names", () => {
for (const shell of ["bash", "zsh", "fish"]) {
const gc = completionTarget(shell, "/home/u", "gc", {});
const gitcode = completionTarget(shell, "/home/u", "gitcode", {});
assert.ok(gc.endsWith("gc") || gc.endsWith("_gc") || gc.endsWith("gc.fish"), shell);
assert.ok(gitcode.endsWith("gitcode") || gitcode.endsWith("_gitcode") || gitcode.endsWith("gitcode.fish"), shell);
assert.notStrictEqual(gc, gitcode, shell);
}
});
test("install gives Homebrew guidance for an Intel Mac Cellar symlink", (t) => {
const root = fs.mkdtempSync(path.join(require("os").tmpdir(), "gc-cellar-case-"));
const brewBin = path.join(root, "usr", "local", "Cellar", "gc", "0.14.0", "bin", "gc");
fs.mkdirSync(path.dirname(brewBin), { recursive: true });
fs.writeFileSync(brewBin, "brew-binary");
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const source = path.join(root, "source");
const alias = path.join(binDir, "gc");
fs.writeFileSync(source, "new");
if (!createFileSymlinkOrSkip(t, path.relative(binDir, brewBin), alias)) return;
assert.throws(
() => replacePath(source, alias, "cellar-case"),
(error) => {
const message = error.message.split(path.sep).join("/");
return /refusing non-regular install target/.test(message) &&
/brew uninstall gc/.test(message);
}
);
});
test("providerIsRunningPackage detects the running package tree as self", () => {
const inside = path.join(__dirname, "..", "bin", "gc.js");
assert.strictEqual(providerIsRunningPackage(inside), true);
assert.strictEqual(providerIsRunningPackage("/usr/local/bin/gc"), false);
assert.strictEqual(providerIsRunningPackage("/opt/other/bin/gitcode"), false);
});
test("completionTarget ignores relative BASH_COMPLETION_USER_DIR entries", () => {
const home = "/u/home";
assert.strictEqual(completionTarget("bash", home, "gc", { BASH_COMPLETION_USER_DIR: "rel/dir" }),
path.join(home, ".local", "share", "bash-completion", "completions", "gc"));
const abs = "/abs dir";
assert.strictEqual(completionTarget("bash", home, "gc", { BASH_COMPLETION_USER_DIR: `rel${path.delimiter}${abs}` }),
path.join(abs, "completions", "gc"));
});