57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
257 lines
8.2 KiB
JavaScript
257 lines
8.2 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"];
|
|
|
|
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" },
|
|
(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 };
|