/** * @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ĩ */ 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/); }); });