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

277 lines
9.1 KiB
JavaScript

/**
* @file Detects whether the dashboard git checkout is behind the canonical
* remote default branch (origin/master or origin/main on a
* direct clone) after a non-destructive fetch. Branch- and fork-aware:
* picks the right remote, recognises feature-branch checkouts, and shapes
* manual_command so it actually closes the gap for the user's situation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
// This build tracks its OWN repository only: origin points at
// git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor. "upstream" is
// deliberately absent from the priority list — a stray upstream remote must
// never make the update checker report commits from somebody else's repo.
const REMOTE_PRIORITY = ["origin"];
// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) that
// leak from a parent git hook process — e.g. this repo's own pre-commit
// hook, which runs `npm run test:server` and therefore this module too.
// Without this, every git call below silently targets the OUTER repo (the
// hook's) instead of `cwd`, since GIT_DIR takes precedence over cwd-based
// discovery. Same scrub `server/lib/worktree.js` already applies.
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;
for (const name of Object.keys(GIT_ENV)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete GIT_ENV[name];
}
function execGit(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 120_000;
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV },
(err, stdout) => {
if (err) reject(err);
else resolve(String(stdout).trim());
}
);
});
}
async function listRemotes(gitRoot) {
try {
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
} catch {
return [];
}
}
async function pickCanonicalRemote(gitRoot) {
const remotes = await listRemotes(gitRoot);
for (const candidate of REMOTE_PRIORITY) {
if (remotes.includes(candidate)) return candidate;
}
return remotes[0] || null;
}
async function resolveCompareRefForRemote(gitRoot, remote) {
const tryRefs = [`${remote}/master`, `${remote}/main`];
for (const ref of tryRefs) {
try {
await execGit(gitRoot, ["rev-parse", "--verify", ref], { timeout: 10_000 });
return ref;
} catch {
// continue
}
}
try {
const sym = await execGit(gitRoot, ["symbolic-ref", `refs/remotes/${remote}/HEAD`], {
timeout: 10_000,
});
const m = sym.match(/^refs\/remotes\/(.+)$/);
if (m) return m[1];
} catch {
// ignore
}
return null;
}
async function getCurrentBranch(gitRoot) {
try {
const branch = await execGit(gitRoot, ["symbolic-ref", "--short", "HEAD"], {
timeout: 10_000,
});
return branch || null;
} catch {
return null; // detached HEAD
}
}
async function getBranchUpstream(gitRoot) {
try {
return await execGit(gitRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
timeout: 10_000,
});
} catch {
return null; // no tracking branch configured
}
}
function stripRemotePrefix(ref) {
// "upstream/master" -> "master"; "origin/feature/foo" -> "feature/foo"
const idx = ref.indexOf("/");
return idx === -1 ? ref : ref.slice(idx + 1);
}
/**
* @param {string} [gitRoot]
* @param {{ skipFetch?: boolean }} [options]
* @returns {Promise<object>}
*/
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
const root = path.resolve(gitRoot);
const gitDir = path.join(root, ".git");
if (!fs.existsSync(gitDir)) {
return {
git_repo: false,
update_available: false,
repo_root: root,
manual_command: null,
message: "Install directory is not a git clone; check for updates manually.",
};
}
const canonicalRemote = await pickCanonicalRemote(root);
if (!canonicalRemote) {
return {
git_repo: true,
update_available: false,
repo_root: root,
remote_ref: null,
local_sha: null,
remote_sha: null,
commits_behind: 0,
message: "No git remotes configured; automatic update check skipped.",
};
}
if (!options.skipFetch) {
try {
await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 });
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
fetch_error: err.message || String(err),
message: `Could not reach ${canonicalRemote}; try again when online.`,
};
}
}
const remoteRef = await resolveCompareRefForRemote(root, canonicalRemote);
if (!remoteRef) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
message: `Could not resolve ${canonicalRemote}/master, ${canonicalRemote}/main, or ${canonicalRemote}/HEAD.`,
};
}
const currentBranch = await getCurrentBranch(root);
const branchUpstream = await getBranchUpstream(root);
const tracksCanonical = branchUpstream === remoteRef;
let localSha;
let remoteSha;
let commitsBehind = 0;
try {
localSha = await execGit(root, ["rev-parse", "HEAD"], { timeout: 10_000 });
remoteSha = await execGit(root, ["rev-parse", remoteRef], { timeout: 10_000 });
const countStr = await execGit(root, ["rev-list", "--count", `HEAD..${remoteRef}`], {
timeout: 30_000,
});
commitsBehind = Number.parseInt(countStr, 10);
if (Number.isNaN(commitsBehind)) commitsBehind = 0;
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
remote_ref: remoteRef,
message: err.message || String(err),
};
}
const updateAvailable = commitsBehind > 0;
const isProd = process.env.NODE_ENV === "production";
const installSteps = ["npm run setup"];
if (isProd) installSteps.push("npm run build");
// Branch-aware manual_command. Three situations:
// 1. tracksCanonical: HEAD's tracked upstream IS the canonical ref. A
// plain `git pull --ff-only` does the right thing — typical clone on
// the default branch.
// 2. Same branch *name* as canonical but different upstream (the fork
// case: local master tracking origin/master, canonical is
// upstream/master). Need to fetch the canonical remote and merge it
// into the local branch.
// 3. Anything else (feature branch, detached HEAD): pulling the current
// branch wouldn't bring in canonical commits, so don't suggest it.
// Offer a fetch and let the user decide how to integrate.
const canonicalBranchName = stripRemotePrefix(remoteRef);
let manualParts;
let situationNote;
let situation;
if (tracksCanonical) {
situation = "tracking_canonical";
manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps];
situationNote = null;
} else if (currentBranch && currentBranch === canonicalBranchName) {
situation = "fork_or_diverged_tracking";
manualParts = [
`cd "${root}"`,
`git fetch ${canonicalRemote}`,
`git merge --ff-only ${remoteRef}`,
...installSteps,
];
situationNote = `You're on '${currentBranch}' tracking '${
branchUpstream || "no upstream"
}'. This command fast-forwards your branch from ${remoteRef} (the canonical default).`;
} else {
situation = currentBranch ? "feature_branch" : "detached_head";
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote}`];
situationNote = currentBranch
? `You're on '${currentBranch}', not the canonical default branch (${remoteRef}). Fetched commits won't be pulled into your branch — rebase or merge ${remoteRef} when you're ready.`
: `HEAD is detached. Fetched commits stay under ${remoteRef}; check out the canonical default branch when ready.`;
}
const manualCommand = manualParts.join(" && ");
return {
git_repo: true,
update_available: updateAvailable,
repo_root: root,
remote_ref: remoteRef,
canonical_remote: canonicalRemote,
current_branch: currentBranch,
tracking_upstream: branchUpstream,
tracks_canonical: tracksCanonical,
situation,
local_sha: localSha,
remote_sha: remoteSha,
commits_behind: commitsBehind,
manual_command: manualCommand,
situation_note: situationNote,
message: updateAvailable
? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
: "Your checkout includes the tip of the canonical default branch.",
};
}
module.exports = { getUpdatesStatus, DEFAULT_ROOT };