Files
nntrivi2001 dfea1a99d6 fix(tests): scrub GIT_* env vars leaking from the pre-commit hook into git-fixture tests
server/lib/update-check.js's execGit() and two test helpers
(lanes-cli.test.js, update-check.test.js) shelled out to git with an
explicit `cwd` but no `env` override. A parent git hook process (this
repo's own .husky/pre-commit, which runs `npm run test:server`) sets
GIT_DIR/GIT_INDEX_FILE in its own environment; those leak to every
child process and take precedence over `cwd` for repo discovery, so
every git command these tests ran against their throwaway tmp repos
was silently redirected at the real repo running the hook instead —
reproduced firsthand as four foreign "init"/"fixture" commits
overwriting a worktree branch mid pre-commit run. Fixes it the same
way server/lib/worktree.js already documented and did for its own git
calls: strip the GIT_* vars before exec.
2026-08-12 09:24:03 +07:00

185 lines
6.8 KiB
JavaScript

/**
* @file Branch- and fork-aware tests for getUpdatesStatus(). Each scenario
* builds throw-away git repos in a tmp dir and asserts the payload shape is
* accurate to the user's situation. skipFetch:true keeps these tests offline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
// process and override an explicit `cwd`, so without this a git command
// meant for this test's throwaway tmp repo silently operates on the real
// repo running the hook instead.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
function git(cwd, args) {
return execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
env: GIT_ENV,
}).trim();
}
function makeBareRemote(parent, name) {
const repo = path.join(parent, `${name}.git`);
fs.mkdirSync(repo, { recursive: true });
// -c init.defaultBranch=master works on every git that supports -c init.*,
// i.e. far older than --initial-branch.
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
stdio: "ignore",
env: GIT_ENV,
});
return repo;
}
function makeWorkingRepo(parent, dir, originUrl) {
const repo = path.join(parent, dir);
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
git(repo, ["remote", "add", "origin", originUrl]);
git(repo, ["push", "-u", "origin", "master"]);
return repo;
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updcheck-"));
});
after(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
});
describe("getUpdatesStatus — local on canonical default branch", () => {
it("with origin only: tracks_canonical=true, command pulls --ff-only", async () => {
const remote = makeBareRemote(tmpDir, "canon1");
const work = makeWorkingRepo(tmpDir, "work1", remote);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.equal(result.situation, "tracking_canonical");
assert.equal(result.situation_note, null);
assert.match(result.manual_command, /git pull --ff-only/);
});
});
describe("getUpdatesStatus — local on a feature branch", () => {
it("does NOT suggest git pull (would pull feature, not master)", async () => {
const remote = makeBareRemote(tmpDir, "canon2");
const work = makeWorkingRepo(tmpDir, "work2", remote);
git(work, ["checkout", "-b", "feature/foo"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, "feature/foo");
assert.equal(result.tracks_canonical, false);
assert.equal(result.situation, "feature_branch");
assert.ok(result.situation_note, "expected a situation_note for feature branches");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(
result.manual_command,
/git pull/,
"must not suggest git pull — would pull feature branch, not canonical"
);
assert.doesNotMatch(
result.manual_command,
/git merge --ff-only/,
"must not auto-merge canonical into the feature branch"
);
});
});
describe("getUpdatesStatus — fork layout (origin = fork, upstream = canonical)", () => {
it("ignores a stray upstream remote and tracks origin only", async () => {
const fork = makeBareRemote(tmpDir, "fork3");
const upstream = makeBareRemote(tmpDir, "upstream3");
const work = makeWorkingRepo(tmpDir, "work3", fork);
// Add a second remote AFTER the working clone so origin stays the fork.
git(work, ["remote", "add", "upstream", upstream]);
git(work, ["push", "upstream", "master"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
// This build tracks its own repository. A remote named `upstream` pointing
// at somebody else's copy must never become the update source.
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.doesNotMatch(result.manual_command, /upstream/);
});
});
describe("getUpdatesStatus — detached HEAD", () => {
it("reports detached_head and only suggests fetch", async () => {
const remote = makeBareRemote(tmpDir, "canon4");
const work = makeWorkingRepo(tmpDir, "work4", remote);
const sha = git(work, ["rev-parse", "HEAD"]);
git(work, ["checkout", sha]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, null);
assert.equal(result.situation, "detached_head");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(result.manual_command, /git pull/);
});
});
describe("getUpdatesStatus — no remotes configured", () => {
it("returns a soft no-remotes payload", async () => {
const repo = path.join(tmpDir, "noremote");
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "lonely\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
const result = await getUpdatesStatus(repo, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.update_available, false);
assert.match(result.message, /No git remotes configured/);
});
});