mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(update): resolve detached dev status from origin (#124611)
* fix(update): resolve detached dev status from origin * test(update): split update status coverage * fix(update): preserve detached dev upstream * fix(update): read detached upstream config
This commit is contained in:
committed by
GitHub
parent
61a3d3e7d9
commit
886e5c0490
@@ -3725,6 +3725,9 @@ describe("update-cli", () => {
|
||||
},
|
||||
assert: () => {
|
||||
expect(getLogOutput()).toContain("OpenClaw update status");
|
||||
expect(checkUpdateStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ useDetachedDevUpstream: false }),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -3759,6 +3762,9 @@ describe("update-cli", () => {
|
||||
const channel = parsed.channel as { value?: unknown; config?: unknown };
|
||||
expect(channel.value).toBe("dev");
|
||||
expect(channel.config).toBe("dev");
|
||||
expect(checkUpdateStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ useDetachedDevUpstream: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses update status --json as the subcommand option", async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise<vo
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: true,
|
||||
useDetachedDevUpstream: configChannel === "dev",
|
||||
includeRegistry: true,
|
||||
resolveRegistryChannel: ({ installKind, git }) =>
|
||||
resolveStatusRegistryUpdateChannel({
|
||||
|
||||
@@ -25,12 +25,12 @@ export const UPDATE_EFFECTIVE_CHANNEL_ENV = "OPENCLAW_UPDATE_EFFECTIVE_CHANNEL";
|
||||
/** Git branch that represents the development update stream. */
|
||||
export const DEV_BRANCH = "main";
|
||||
|
||||
/** Resolves current tracking, or the configured Dev branch for detached HEAD. */
|
||||
export function resolveDevUpstreamRef(branch?: string | null, detached = false): string | null {
|
||||
if (branch !== "HEAD") {
|
||||
return "@{upstream}";
|
||||
}
|
||||
return detached ? `${DEV_BRANCH}@{upstream}` : null;
|
||||
/** Orders the configured Dev upstream before any detached-checkout fallbacks. */
|
||||
export function resolveDevUpstreamRefs(
|
||||
detached: boolean,
|
||||
fallbacks: readonly string[] = [],
|
||||
): string[] {
|
||||
return detached ? [`${DEV_BRANCH}@{upstream}`, ...fallbacks] : ["@{upstream}"];
|
||||
}
|
||||
|
||||
/** Normalizes config or CLI channel input to a supported update channel. */
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
// Covers install, dependency, and Git update status.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { withTestDir } from "../test-helpers/temp-dir.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { checkUpdateStatus } from "./update-check.js";
|
||||
|
||||
async function runGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const result = await runCommandWithTimeout(["git", ...args], { cwd, timeoutMs: 5000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function initGitRepo(root: string): Promise<void> {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await runGit(root, "init", "--initial-branch=main");
|
||||
await runGit(root, "config", "user.name", "OpenClaw Test");
|
||||
await runGit(root, "config", "user.email", "test@openclaw.invalid");
|
||||
}
|
||||
|
||||
async function commitGit(root: string, message: string): Promise<void> {
|
||||
await runGit(root, "commit", "--allow-empty", "--message", message);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("checkUpdateStatus", () => {
|
||||
it("fetches a retained main upstream whose remote nickname contains a slash", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-slash-remote-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(sourceRoot);
|
||||
await commitGit(sourceRoot, "base");
|
||||
await runGit(base, "clone", "--quiet", sourceRoot, localRoot);
|
||||
const detachedSha = await runGit(localRoot, "rev-parse", "HEAD");
|
||||
await runGit(localRoot, "remote", "add", "foo/bar", sourceRoot);
|
||||
await runGit(localRoot, "fetch", "foo/bar", "+refs/heads/main:refs/remotes/foo/bar/main");
|
||||
await runGit(localRoot, "branch", "--set-upstream-to=foo/bar/main", "main");
|
||||
await runGit(localRoot, "checkout", "--detach", detachedSha);
|
||||
await runGit(localRoot, "remote", "set-url", "origin", path.join(base, "missing"));
|
||||
await commitGit(sourceRoot, "newer");
|
||||
const upstreamSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: true,
|
||||
timeoutMs: 5000,
|
||||
useDetachedDevUpstream: true,
|
||||
});
|
||||
|
||||
expect(status.git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: detachedSha,
|
||||
upstream: "foo/bar/main",
|
||||
upstreamSource: "tracking",
|
||||
upstreamSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
fetchOk: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a retained main branch's configured non-origin upstream", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-configured-upstream-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(sourceRoot);
|
||||
await commitGit(sourceRoot, "base");
|
||||
await runGit(base, "clone", "--quiet", sourceRoot, localRoot);
|
||||
const detachedSha = await runGit(localRoot, "rev-parse", "HEAD");
|
||||
await runGit(localRoot, "remote", "add", "upstream", sourceRoot);
|
||||
await runGit(localRoot, "fetch", "upstream", "+refs/heads/main:refs/remotes/upstream/main");
|
||||
await runGit(localRoot, "branch", "--set-upstream-to=upstream/main", "main");
|
||||
await runGit(localRoot, "checkout", "--detach", detachedSha);
|
||||
await runGit(localRoot, "remote", "set-url", "origin", path.join(base, "missing"));
|
||||
await commitGit(sourceRoot, "newer");
|
||||
const upstreamSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: true,
|
||||
timeoutMs: 5000,
|
||||
useDetachedDevUpstream: true,
|
||||
});
|
||||
|
||||
expect(status.git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: detachedSha,
|
||||
upstream: "upstream/main",
|
||||
upstreamSource: "tracking",
|
||||
upstreamSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
fetchOk: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves manager-style detached dev tracking before matching update receipts", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-receipt-fallback-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(sourceRoot);
|
||||
await fs.writeFile(
|
||||
path.join(sourceRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
|
||||
);
|
||||
await runGit(sourceRoot, "add", "package.json");
|
||||
await commitGit(sourceRoot, "base");
|
||||
const baseSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await commitGit(sourceRoot, "target");
|
||||
const targetSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await runGit(base, "clone", "--quiet", "--no-checkout", sourceRoot, localRoot);
|
||||
await runGit(localRoot, "checkout", "--detach", targetSha);
|
||||
await runGit(localRoot, "branch", "-D", "main");
|
||||
expect(await runGit(localRoot, "branch", "--list", "main")).toBe("");
|
||||
const fallback = { currentSha: targetSha, upstreamRef: "origin/main" };
|
||||
const readStatus = (
|
||||
params: { fetch?: boolean; fallback?: typeof fallback; detached?: boolean } = {},
|
||||
) =>
|
||||
checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: params.fetch ?? false,
|
||||
timeoutMs: 5000,
|
||||
useDetachedDevUpstream: params.detached ?? true,
|
||||
...(params.fallback ? { gitUpstreamFallback: params.fallback } : {}),
|
||||
});
|
||||
|
||||
expect((await readStatus({ fetch: true })).git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: targetSha,
|
||||
upstream: "origin/main",
|
||||
upstreamSource: "tracking",
|
||||
upstreamSha: targetSha,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fetchOk: true,
|
||||
});
|
||||
|
||||
const current = await readStatus({ fetch: true, fallback, detached: false });
|
||||
expect(current.git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: targetSha,
|
||||
upstream: "origin/main",
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: targetSha,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
await commitGit(sourceRoot, "newer");
|
||||
const newerSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
expect((await readStatus({ fetch: true })).git).toMatchObject({
|
||||
upstream: "origin/main",
|
||||
upstreamSource: "tracking",
|
||||
upstreamSha: newerSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
fetchOk: true,
|
||||
});
|
||||
const behind = await readStatus({ fetch: true, fallback, detached: false });
|
||||
expect(behind.git).toMatchObject({
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: newerSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
});
|
||||
|
||||
for (const fallbackOverride of [undefined, { ...fallback, currentSha: baseSha }]) {
|
||||
const unmanaged = await readStatus({ fallback: fallbackOverride, detached: false });
|
||||
expect(unmanaged.git).toMatchObject({ branch: "HEAD", upstream: null });
|
||||
expect(unmanaged.git).not.toHaveProperty("upstreamSource");
|
||||
}
|
||||
|
||||
await runGit(localRoot, "checkout", "-b", "receipt-collision", targetSha);
|
||||
const namedBranch = await readStatus({ fallback });
|
||||
expect(namedBranch.git).toMatchObject({
|
||||
branch: "receipt-collision",
|
||||
sha: targetSha,
|
||||
upstream: null,
|
||||
upstreamSha: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
expect(namedBranch.git).not.toHaveProperty("upstreamSource");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat stale remote refs as current when fetch fails", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-fetch-failure-" }, async (base) => {
|
||||
const remoteRoot = path.join(base, "remote");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(remoteRoot);
|
||||
await commitGit(remoteRoot, "initial");
|
||||
await runGit(base, "clone", "--quiet", remoteRoot, localRoot);
|
||||
await runGit(localRoot, "remote", "set-url", "origin", path.join(base, "missing"));
|
||||
const commitAtMs =
|
||||
Number(await runGit(localRoot, "show", "-s", "--format=%ct", "HEAD")) * 1000;
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: true,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(status.git).toMatchObject({
|
||||
upstream: "origin/main",
|
||||
upstreamSha: null,
|
||||
commitAtMs,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
fetchOk: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report divergence for unrelated histories", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-unrelated-" }, async (base) => {
|
||||
const localRoot = path.join(base, "local");
|
||||
const remoteRoot = path.join(base, "remote");
|
||||
await initGitRepo(localRoot);
|
||||
await commitGit(localRoot, "local history");
|
||||
await initGitRepo(remoteRoot);
|
||||
await commitGit(remoteRoot, "remote history");
|
||||
|
||||
await runGit(localRoot, "remote", "add", "origin", remoteRoot);
|
||||
await runGit(localRoot, "fetch", "origin", "main");
|
||||
await runGit(localRoot, "branch", "--set-upstream-to=origin/main", "main");
|
||||
|
||||
const mergeBase = await runCommandWithTimeout(["git", "merge-base", "HEAD", "origin/main"], {
|
||||
cwd: localRoot,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(mergeBase.code).toBe(1);
|
||||
expect(
|
||||
await runGit(localRoot, "rev-list", "--left-right", "--count", "HEAD...origin/main"),
|
||||
).toMatch(/^1\s+1$/u);
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(status.git).toMatchObject({
|
||||
upstream: "origin/main",
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reports divergence only when shallow history retains a merge base", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-shallow-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
await initGitRepo(sourceRoot);
|
||||
await commitGit(sourceRoot, "common base");
|
||||
await runGit(sourceRoot, "switch", "--create", "feature");
|
||||
await commitGit(sourceRoot, "feature change");
|
||||
await runGit(sourceRoot, "switch", "main");
|
||||
await commitGit(sourceRoot, "main change");
|
||||
const mainSha = await runGit(sourceRoot, "rev-parse", "main");
|
||||
|
||||
const cloneDivergedHistory = async (name: string, depth?: number) => {
|
||||
const cloneRoot = path.join(base, name);
|
||||
const depthArgs = depth ? [`--depth=${depth}`] : [];
|
||||
await runGit(
|
||||
base,
|
||||
"clone",
|
||||
"--quiet",
|
||||
...depthArgs,
|
||||
"--branch",
|
||||
"feature",
|
||||
pathToFileURL(sourceRoot).href,
|
||||
cloneRoot,
|
||||
);
|
||||
await runGit(
|
||||
cloneRoot,
|
||||
"fetch",
|
||||
"--quiet",
|
||||
...(depth ? [`--depth=${depth}`] : []),
|
||||
"origin",
|
||||
"+refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
await runGit(
|
||||
cloneRoot,
|
||||
"config",
|
||||
"--add",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
await runGit(cloneRoot, "config", "branch.feature.remote", "origin");
|
||||
await runGit(cloneRoot, "config", "branch.feature.merge", "refs/heads/main");
|
||||
return cloneRoot;
|
||||
};
|
||||
|
||||
const readDivergence = async (root: string) => {
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return {
|
||||
ahead: status.git?.ahead,
|
||||
behind: status.git?.behind,
|
||||
upstreamSha: status.git?.upstreamSha,
|
||||
};
|
||||
};
|
||||
|
||||
const fullRoot = await cloneDivergedHistory("full");
|
||||
await expect(readDivergence(fullRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
await runGit(fullRoot, "remote", "rename", "--", "origin", "-dash");
|
||||
expect(await runGit(fullRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe("-dash/main");
|
||||
await expect(readDivergence(fullRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
|
||||
const truncatedRoot = await cloneDivergedHistory("shallow-depth-1", 1);
|
||||
await expect(readDivergence(truncatedRoot)).resolves.toEqual({
|
||||
ahead: null,
|
||||
behind: null,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
|
||||
const comparableRoot = await cloneDivergedHistory("shallow-depth-2", 2);
|
||||
await expect(readDivergence(comparableRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unknown install status when root is missing", async () => {
|
||||
await expect(
|
||||
checkUpdateStatus({ root: null, includeRegistry: false, timeoutMs: 1000 }),
|
||||
).resolves.toEqual({
|
||||
root: null,
|
||||
installKind: "unknown",
|
||||
packageManager: "unknown",
|
||||
registry: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects package installs for non-git roots", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "npm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8");
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(status.root).toBe(root);
|
||||
expect(status.installKind).toBe("package");
|
||||
expect(status.packageManager).toBe("npm");
|
||||
expect(status.git).toBeUndefined();
|
||||
expect(status.registry).toBeUndefined();
|
||||
expect(status.deps?.manager).toBe("npm");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a status registry channel after detecting the install kind", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-registry-channel-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "npm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8");
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
const resolveRegistryChannel = vi.fn(() => "extended-stable" as const);
|
||||
|
||||
await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
resolveRegistryChannel,
|
||||
});
|
||||
|
||||
expect(resolveRegistryChannel).toHaveBeenCalledWith({
|
||||
installKind: "package",
|
||||
git: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "text lockfile",
|
||||
lockfiles: ["bun.lock"],
|
||||
expectedLockfile: "bun.lock",
|
||||
},
|
||||
{
|
||||
name: "binary lockfile",
|
||||
lockfiles: ["bun.lockb"],
|
||||
expectedLockfile: "bun.lockb",
|
||||
},
|
||||
{
|
||||
name: "text lockfile when both formats exist",
|
||||
lockfiles: ["bun.lock", "bun.lockb"],
|
||||
expectedLockfile: "bun.lock",
|
||||
},
|
||||
])("reports dependency status for Bun's $name", async ({ lockfiles, expectedLockfile }) => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-bun-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "bun@1.2.0" }),
|
||||
"utf8",
|
||||
);
|
||||
for (const lockfile of lockfiles) {
|
||||
await fs.writeFile(path.join(root, lockfile), "lock", "utf8");
|
||||
}
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
installKind: "package",
|
||||
packageManager: "bun",
|
||||
deps: {
|
||||
manager: "bun",
|
||||
lockfilePath: path.join(root, expectedLockfile),
|
||||
markerPath: path.join(root, "node_modules"),
|
||||
status: "ok",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ manager: "npm", expectedLockfile: "package-lock.json" },
|
||||
{ manager: "bun", expectedLockfile: "bun.lockb" },
|
||||
])(
|
||||
"detects lockless OpenClaw $manager installs despite packed pnpm metadata",
|
||||
async ({ manager, expectedLockfile }) => {
|
||||
await withTestDir({ prefix: `openclaw-update-check-lockless-${manager}-` }, async (base) => {
|
||||
const bunInstall = path.join(base, "custom-bun-home");
|
||||
const root =
|
||||
manager === "bun"
|
||||
? path.join(bunInstall, "install", "global", "node_modules", "openclaw")
|
||||
: path.join(base, "prefix", "node_modules", "openclaw");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(status.installKind).toBe("package");
|
||||
expect(status.packageManager).toBe(manager);
|
||||
expect(status.deps).toMatchObject({
|
||||
manager,
|
||||
lockfilePath: path.join(root, expectedLockfile),
|
||||
status: "unknown",
|
||||
reason: "lockfile missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a missing dependency marker and accepts an older valid marker", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-deps-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
|
||||
"utf8",
|
||||
);
|
||||
const lockfilePath = path.join(root, "pnpm-lock.yaml");
|
||||
await fs.writeFile(lockfilePath, "lock", "utf8");
|
||||
|
||||
const missing = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(missing.deps).toMatchObject({
|
||||
manager: "pnpm",
|
||||
status: "missing",
|
||||
reason: "node_modules marker missing",
|
||||
});
|
||||
|
||||
const markerPath = path.join(root, "node_modules", ".modules.yaml");
|
||||
await fs.mkdir(path.dirname(markerPath), { recursive: true });
|
||||
await fs.writeFile(markerPath, "marker", "utf8");
|
||||
const staleDate = new Date(Date.now() - 10_000);
|
||||
const freshDate = new Date();
|
||||
await fs.utimes(markerPath, staleDate, staleDate);
|
||||
await fs.utimes(lockfilePath, freshDate, freshDate);
|
||||
|
||||
const installed = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(installed.deps).toMatchObject({
|
||||
manager: "pnpm",
|
||||
status: "ok",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("treats symlinked git installs as git roots", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-git-" }, async (base) => {
|
||||
const repoRoot = path.join(base, "repo");
|
||||
const linkedRoot = path.join(base, "linked-openclaw");
|
||||
await fs.mkdir(repoRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await runCommandWithTimeout(["git", "init"], { cwd: repoRoot, timeoutMs: 1000 });
|
||||
await fs.symlink(repoRoot, linkedRoot);
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: linkedRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(status.root).toBe(linkedRoot);
|
||||
expect(status.installKind).toBe("git");
|
||||
expect(status.git?.root).toBe(linkedRoot);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
// Covers update status, dependency status, and registry fetch helpers.
|
||||
// Covers update version resolution, Git install labels, and registry fetch helpers.
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { withTestDir } from "../test-helpers/temp-dir.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { useMockHttp } from "../test-utils/mock-http.js";
|
||||
import { fetchNpmPackageTargetStatus } from "./update-check-package-target.js";
|
||||
import {
|
||||
@@ -21,25 +19,6 @@ import {
|
||||
|
||||
const mockHttp = useMockHttp();
|
||||
|
||||
async function runGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const result = await runCommandWithTimeout(["git", ...args], { cwd, timeoutMs: 5000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function initGitRepo(root: string): Promise<void> {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await runGit(root, "init", "--initial-branch=main");
|
||||
await runGit(root, "config", "user.name", "OpenClaw Test");
|
||||
await runGit(root, "config", "user.email", "test@openclaw.invalid");
|
||||
}
|
||||
|
||||
async function commitGit(root: string, message: string): Promise<void> {
|
||||
await runGit(root, "commit", "--allow-empty", "--message", message);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -626,449 +605,7 @@ describe("formatGitInstallLabel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateStatus", () => {
|
||||
it("resolves detached dev tracking before matching update receipts", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-receipt-fallback-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(sourceRoot);
|
||||
await fs.writeFile(
|
||||
path.join(sourceRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
|
||||
);
|
||||
await runGit(sourceRoot, "add", "package.json");
|
||||
await commitGit(sourceRoot, "base");
|
||||
const baseSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await commitGit(sourceRoot, "target");
|
||||
const targetSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await runGit(base, "clone", "--quiet", sourceRoot, localRoot);
|
||||
await runGit(localRoot, "checkout", "--detach", targetSha);
|
||||
const fallback = { currentSha: targetSha, upstreamRef: "origin/main" };
|
||||
const readStatus = (params: { fetch?: boolean; fallback?: typeof fallback } = {}) =>
|
||||
checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: params.fetch ?? false,
|
||||
timeoutMs: 5000,
|
||||
useDetachedDevUpstream: true,
|
||||
...(params.fallback ? { gitUpstreamFallback: params.fallback } : {}),
|
||||
});
|
||||
|
||||
expect((await readStatus()).git?.upstream).toBe("origin/main");
|
||||
await runGit(localRoot, "branch", "--unset-upstream", "main");
|
||||
expect((await readStatus()).git?.upstream).toBeNull();
|
||||
|
||||
const current = await readStatus({ fetch: true, fallback });
|
||||
expect(current.git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: targetSha,
|
||||
upstream: "origin/main",
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: targetSha,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
await commitGit(sourceRoot, "newer");
|
||||
const newerSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
const behind = await readStatus({ fetch: true, fallback });
|
||||
expect(behind.git).toMatchObject({
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: newerSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
});
|
||||
|
||||
for (const fallbackOverride of [undefined, { ...fallback, currentSha: baseSha }]) {
|
||||
const unmanaged = await readStatus({ fallback: fallbackOverride });
|
||||
expect(unmanaged.git).toMatchObject({ branch: "HEAD", upstream: null });
|
||||
expect(unmanaged.git).not.toHaveProperty("upstreamSource");
|
||||
}
|
||||
|
||||
await runGit(localRoot, "checkout", "-b", "receipt-collision", targetSha);
|
||||
const namedBranch = await readStatus({ fallback });
|
||||
expect(namedBranch.git).toMatchObject({
|
||||
branch: "receipt-collision",
|
||||
sha: targetSha,
|
||||
upstream: null,
|
||||
upstreamSha: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
expect(namedBranch.git).not.toHaveProperty("upstreamSource");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat stale remote refs as current when fetch fails", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-fetch-failure-" }, async (base) => {
|
||||
const remoteRoot = path.join(base, "remote");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(remoteRoot);
|
||||
await commitGit(remoteRoot, "initial");
|
||||
await runGit(base, "clone", "--quiet", remoteRoot, localRoot);
|
||||
await runGit(localRoot, "remote", "set-url", "origin", path.join(base, "missing"));
|
||||
const commitAtMs =
|
||||
Number(await runGit(localRoot, "show", "-s", "--format=%ct", "HEAD")) * 1000;
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: true,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(status.git).toMatchObject({
|
||||
upstream: "origin/main",
|
||||
upstreamSha: null,
|
||||
commitAtMs,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
fetchOk: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report divergence for unrelated histories", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-unrelated-" }, async (base) => {
|
||||
const localRoot = path.join(base, "local");
|
||||
const remoteRoot = path.join(base, "remote");
|
||||
await initGitRepo(localRoot);
|
||||
await commitGit(localRoot, "local history");
|
||||
await initGitRepo(remoteRoot);
|
||||
await commitGit(remoteRoot, "remote history");
|
||||
|
||||
await runGit(localRoot, "remote", "add", "origin", remoteRoot);
|
||||
await runGit(localRoot, "fetch", "origin", "main");
|
||||
await runGit(localRoot, "branch", "--set-upstream-to=origin/main", "main");
|
||||
|
||||
const mergeBase = await runCommandWithTimeout(["git", "merge-base", "HEAD", "origin/main"], {
|
||||
cwd: localRoot,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(mergeBase.code).toBe(1);
|
||||
expect(
|
||||
await runGit(localRoot, "rev-list", "--left-right", "--count", "HEAD...origin/main"),
|
||||
).toMatch(/^1\s+1$/u);
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(status.git).toMatchObject({
|
||||
upstream: "origin/main",
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reports divergence only when shallow history retains a merge base", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-shallow-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
await initGitRepo(sourceRoot);
|
||||
await commitGit(sourceRoot, "common base");
|
||||
await runGit(sourceRoot, "switch", "--create", "feature");
|
||||
await commitGit(sourceRoot, "feature change");
|
||||
await runGit(sourceRoot, "switch", "main");
|
||||
await commitGit(sourceRoot, "main change");
|
||||
const mainSha = await runGit(sourceRoot, "rev-parse", "main");
|
||||
|
||||
const cloneDivergedHistory = async (name: string, depth?: number) => {
|
||||
const cloneRoot = path.join(base, name);
|
||||
const depthArgs = depth ? [`--depth=${depth}`] : [];
|
||||
await runGit(
|
||||
base,
|
||||
"clone",
|
||||
"--quiet",
|
||||
...depthArgs,
|
||||
"--branch",
|
||||
"feature",
|
||||
pathToFileURL(sourceRoot).href,
|
||||
cloneRoot,
|
||||
);
|
||||
await runGit(
|
||||
cloneRoot,
|
||||
"fetch",
|
||||
"--quiet",
|
||||
...(depth ? [`--depth=${depth}`] : []),
|
||||
"origin",
|
||||
"+refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
await runGit(
|
||||
cloneRoot,
|
||||
"config",
|
||||
"--add",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
await runGit(cloneRoot, "config", "branch.feature.remote", "origin");
|
||||
await runGit(cloneRoot, "config", "branch.feature.merge", "refs/heads/main");
|
||||
return cloneRoot;
|
||||
};
|
||||
|
||||
const readDivergence = async (root: string) => {
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return {
|
||||
ahead: status.git?.ahead,
|
||||
behind: status.git?.behind,
|
||||
upstreamSha: status.git?.upstreamSha,
|
||||
};
|
||||
};
|
||||
|
||||
const fullRoot = await cloneDivergedHistory("full");
|
||||
await expect(readDivergence(fullRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
await runGit(fullRoot, "remote", "rename", "--", "origin", "-dash");
|
||||
expect(await runGit(fullRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe("-dash/main");
|
||||
await expect(readDivergence(fullRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
|
||||
const truncatedRoot = await cloneDivergedHistory("shallow-depth-1", 1);
|
||||
await expect(readDivergence(truncatedRoot)).resolves.toEqual({
|
||||
ahead: null,
|
||||
behind: null,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
|
||||
const comparableRoot = await cloneDivergedHistory("shallow-depth-2", 2);
|
||||
await expect(readDivergence(comparableRoot)).resolves.toEqual({
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
upstreamSha: mainSha,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unknown install status when root is missing", async () => {
|
||||
await expect(
|
||||
checkUpdateStatus({ root: null, includeRegistry: false, timeoutMs: 1000 }),
|
||||
).resolves.toEqual({
|
||||
root: null,
|
||||
installKind: "unknown",
|
||||
packageManager: "unknown",
|
||||
registry: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects package installs for non-git roots", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "npm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8");
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(status.root).toBe(root);
|
||||
expect(status.installKind).toBe("package");
|
||||
expect(status.packageManager).toBe("npm");
|
||||
expect(status.git).toBeUndefined();
|
||||
expect(status.registry).toBeUndefined();
|
||||
expect(status.deps?.manager).toBe("npm");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a status registry channel after detecting the install kind", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-registry-channel-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "npm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8");
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
const resolveRegistryChannel = vi.fn(() => "extended-stable" as const);
|
||||
|
||||
await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
resolveRegistryChannel,
|
||||
});
|
||||
|
||||
expect(resolveRegistryChannel).toHaveBeenCalledWith({
|
||||
installKind: "package",
|
||||
git: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "text lockfile",
|
||||
lockfiles: ["bun.lock"],
|
||||
expectedLockfile: "bun.lock",
|
||||
},
|
||||
{
|
||||
name: "binary lockfile",
|
||||
lockfiles: ["bun.lockb"],
|
||||
expectedLockfile: "bun.lockb",
|
||||
},
|
||||
{
|
||||
name: "text lockfile when both formats exist",
|
||||
lockfiles: ["bun.lock", "bun.lockb"],
|
||||
expectedLockfile: "bun.lock",
|
||||
},
|
||||
])("reports dependency status for Bun's $name", async ({ lockfiles, expectedLockfile }) => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-bun-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "bun@1.2.0" }),
|
||||
"utf8",
|
||||
);
|
||||
for (const lockfile of lockfiles) {
|
||||
await fs.writeFile(path.join(root, lockfile), "lock", "utf8");
|
||||
}
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
installKind: "package",
|
||||
packageManager: "bun",
|
||||
deps: {
|
||||
manager: "bun",
|
||||
lockfilePath: path.join(root, expectedLockfile),
|
||||
markerPath: path.join(root, "node_modules"),
|
||||
status: "ok",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ manager: "npm", expectedLockfile: "package-lock.json" },
|
||||
{ manager: "bun", expectedLockfile: "bun.lockb" },
|
||||
])(
|
||||
"detects lockless OpenClaw $manager installs despite packed pnpm metadata",
|
||||
async ({ manager, expectedLockfile }) => {
|
||||
await withTestDir({ prefix: `openclaw-update-check-lockless-${manager}-` }, async (base) => {
|
||||
const bunInstall = path.join(base, "custom-bun-home");
|
||||
const root =
|
||||
manager === "bun"
|
||||
? path.join(bunInstall, "install", "global", "node_modules", "openclaw")
|
||||
: path.join(base, "prefix", "node_modules", "openclaw");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(status.installKind).toBe("package");
|
||||
expect(status.packageManager).toBe(manager);
|
||||
expect(status.deps).toMatchObject({
|
||||
manager,
|
||||
lockfilePath: path.join(root, expectedLockfile),
|
||||
status: "unknown",
|
||||
reason: "lockfile missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a missing dependency marker and accepts an older valid marker", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-deps-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
|
||||
"utf8",
|
||||
);
|
||||
const lockfilePath = path.join(root, "pnpm-lock.yaml");
|
||||
await fs.writeFile(lockfilePath, "lock", "utf8");
|
||||
|
||||
const missing = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(missing.deps).toMatchObject({
|
||||
manager: "pnpm",
|
||||
status: "missing",
|
||||
reason: "node_modules marker missing",
|
||||
});
|
||||
|
||||
const markerPath = path.join(root, "node_modules", ".modules.yaml");
|
||||
await fs.mkdir(path.dirname(markerPath), { recursive: true });
|
||||
await fs.writeFile(markerPath, "marker", "utf8");
|
||||
const staleDate = new Date(Date.now() - 10_000);
|
||||
const freshDate = new Date();
|
||||
await fs.utimes(markerPath, staleDate, staleDate);
|
||||
await fs.utimes(lockfilePath, freshDate, freshDate);
|
||||
|
||||
const installed = await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(installed.deps).toMatchObject({
|
||||
manager: "pnpm",
|
||||
status: "ok",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("treats symlinked git installs as git roots", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-git-" }, async (base) => {
|
||||
const repoRoot = path.join(base, "repo");
|
||||
const linkedRoot = path.join(base, "linked-openclaw");
|
||||
await fs.mkdir(repoRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await runCommandWithTimeout(["git", "init"], { cwd: repoRoot, timeoutMs: 1000 });
|
||||
await fs.symlink(repoRoot, linkedRoot);
|
||||
|
||||
const status = await checkUpdateStatus({
|
||||
root: linkedRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(status.root).toBe(linkedRoot);
|
||||
expect(status.installKind).toBe("git");
|
||||
expect(status.git?.root).toBe(linkedRoot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateStatus registry behavior", () => {
|
||||
it("reports unsupported_git_channel for Git status without querying npm", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-check-git-channel-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
|
||||
+76
-76
@@ -10,7 +10,12 @@ import {
|
||||
} from "./detect-package-manager.js";
|
||||
import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js";
|
||||
import { compareValidSemver, normalizeLegacyDotBetaVersion } from "./semver.js";
|
||||
import { channelToNpmTag, resolveDevUpstreamRef, type UpdateChannel } from "./update-channels.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEV_BRANCH,
|
||||
resolveDevUpstreamRefs,
|
||||
type UpdateChannel,
|
||||
} from "./update-channels.js";
|
||||
import {
|
||||
fetchNpmPackageTargetStatus,
|
||||
type NpmMetadataCommandRunner,
|
||||
@@ -34,6 +39,12 @@ type GitUpdateStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GitTrackingTarget = {
|
||||
revision: string;
|
||||
display: string;
|
||||
fetch: "prune" | { remote: string; mergeRef: string };
|
||||
};
|
||||
|
||||
type DepsStatus = {
|
||||
manager: PackageManager;
|
||||
status: "ok" | "missing" | "unknown";
|
||||
@@ -234,6 +245,12 @@ async function checkGitUpdateStatus(params: {
|
||||
}): Promise<GitUpdateStatus> {
|
||||
const timeoutMs = params.timeoutMs ?? 6000;
|
||||
const root = path.resolve(params.root);
|
||||
const runGit = (...args: string[]) =>
|
||||
runCommandWithTimeout(["git", "-C", root, ...args], { timeoutMs }).catch(() => null);
|
||||
const readGit = async (...args: string[]) => {
|
||||
const result = await runGit(...args);
|
||||
return result?.code === 0 ? result.stdout.trim() || null : null;
|
||||
};
|
||||
|
||||
const base: GitUpdateStatus = {
|
||||
root,
|
||||
@@ -249,56 +266,60 @@ async function checkGitUpdateStatus(params: {
|
||||
fetchOk: null,
|
||||
};
|
||||
|
||||
const [branchRes, shaRes, commitAtRes, tagRes, dirtyRes] = await Promise.all([
|
||||
runCommandWithTimeout(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null),
|
||||
runCommandWithTimeout(["git", "-C", root, "rev-parse", "HEAD"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null),
|
||||
runCommandWithTimeout(["git", "-C", root, "show", "-s", "--format=%ct", "HEAD"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null),
|
||||
runCommandWithTimeout(["git", "-C", root, "describe", "--tags", "--exact-match"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null),
|
||||
runCommandWithTimeout(
|
||||
["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"],
|
||||
{
|
||||
timeoutMs,
|
||||
},
|
||||
).catch(() => null),
|
||||
const [branchRes, sha, commitAtRaw, tag, dirtyRes] = await Promise.all([
|
||||
runGit("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
readGit("rev-parse", "HEAD"),
|
||||
readGit("show", "-s", "--format=%ct", "HEAD"),
|
||||
readGit("describe", "--tags", "--exact-match"),
|
||||
runGit("status", "--porcelain", "--", ":!dist/control-ui/"),
|
||||
]);
|
||||
if (!branchRes || branchRes.code !== 0) {
|
||||
return { ...base, error: branchRes?.stderr?.trim() || "git unavailable" };
|
||||
}
|
||||
const branch = branchRes.stdout.trim() || null;
|
||||
const trackingRevision = resolveDevUpstreamRef(branch, params.useDetachedDevUpstream);
|
||||
const upstreamRes = trackingRevision
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", trackingRevision],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
const trackingRevisions =
|
||||
branch === "HEAD"
|
||||
? params.useDetachedDevUpstream
|
||||
? resolveDevUpstreamRefs(true, [`refs/remotes/origin/${DEV_BRANCH}`])
|
||||
: []
|
||||
: resolveDevUpstreamRefs(false);
|
||||
let tracking: GitTrackingTarget | null = null;
|
||||
for (const revision of trackingRevisions) {
|
||||
const display = await readGit("rev-parse", "--abbrev-ref", "--symbolic-full-name", revision);
|
||||
if (!display) {
|
||||
continue;
|
||||
}
|
||||
let fetch: GitTrackingTarget["fetch"] = "prune";
|
||||
if (branch === "HEAD") {
|
||||
if (revision === `${DEV_BRANCH}@{upstream}`) {
|
||||
const [remote, mergeRef] = await Promise.all([
|
||||
readGit("config", "--get", `branch.${DEV_BRANCH}.remote`),
|
||||
readGit("config", "--get", `branch.${DEV_BRANCH}.merge`),
|
||||
]);
|
||||
if (!remote || !mergeRef) {
|
||||
continue;
|
||||
}
|
||||
fetch = { remote, mergeRef };
|
||||
} else {
|
||||
fetch = { remote: "origin", mergeRef: `refs/heads/${DEV_BRANCH}` };
|
||||
}
|
||||
}
|
||||
tracking = { revision, display, fetch };
|
||||
break;
|
||||
}
|
||||
|
||||
const sha = shaRes && shaRes.code === 0 ? shaRes.stdout.trim() : null;
|
||||
const commitAtSeconds =
|
||||
commitAtRes?.code === 0 ? Number.parseInt(commitAtRes.stdout.trim(), 10) : Number.NaN;
|
||||
const commitAtSeconds = Number.parseInt(commitAtRaw ?? "", 10);
|
||||
const commitAtMs = Number.isSafeInteger(commitAtSeconds) ? commitAtSeconds * 1000 : null;
|
||||
|
||||
const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null;
|
||||
|
||||
const trackingUpstream =
|
||||
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() || null : null;
|
||||
const receiptUpstream =
|
||||
!trackingUpstream &&
|
||||
!tracking &&
|
||||
branch === "HEAD" &&
|
||||
sha &&
|
||||
params.upstreamFallback?.currentSha.trim().toLowerCase() === sha.toLowerCase()
|
||||
? params.upstreamFallback.upstreamRef.trim() || null
|
||||
: null;
|
||||
const upstream = trackingUpstream ?? receiptUpstream;
|
||||
const upstreamSource = trackingUpstream
|
||||
const upstream = tracking?.display ?? receiptUpstream;
|
||||
const upstreamSource = tracking
|
||||
? ("tracking" as const)
|
||||
: receiptUpstream
|
||||
? ("receipt" as const)
|
||||
@@ -306,53 +327,32 @@ async function checkGitUpdateStatus(params: {
|
||||
|
||||
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
|
||||
const fetchTarget =
|
||||
tracking?.fetch && tracking.fetch !== "prune"
|
||||
? [
|
||||
"--",
|
||||
tracking.fetch.remote,
|
||||
`+${tracking.fetch.mergeRef}:refs/remotes/${tracking.display}`,
|
||||
]
|
||||
: ["--prune"];
|
||||
const fetchOk = params.fetch
|
||||
? await runCommandWithTimeout(["git", "-C", root, "fetch", "--quiet", "--prune"], { timeoutMs })
|
||||
.then((r) => r.code === 0)
|
||||
.catch(() => false)
|
||||
? (await runGit("fetch", "--quiet", ...fetchTarget))?.code === 0
|
||||
: null;
|
||||
|
||||
const canCompareUpstream = !params.fetch || fetchOk === true;
|
||||
|
||||
// Freeze the post-fetch upstream for both graph queries. Active tracking wins;
|
||||
// a matching successful update receipt keeps intentional detached installs comparable.
|
||||
const upstreamRevision = `${upstreamSource === "tracking" ? trackingRevision : upstream}^{commit}`;
|
||||
const upstreamCommitRes =
|
||||
canCompareUpstream && upstream && sha
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--verify", upstreamRevision],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
const upstreamRevision = `${upstreamSource === "tracking" ? tracking?.revision : upstream}^{commit}`;
|
||||
const upstreamCommit =
|
||||
upstreamCommitRes?.code === 0 ? upstreamCommitRes.stdout.trim() || null : null;
|
||||
const mergeBase =
|
||||
sha && upstreamCommit
|
||||
? await runCommandWithTimeout(["git", "-C", root, "merge-base", sha, upstreamCommit], {
|
||||
timeoutMs,
|
||||
}).catch(() => null)
|
||||
(!params.fetch || fetchOk === true) && upstream && sha
|
||||
? await readGit("rev-parse", "--verify", upstreamRevision)
|
||||
: null;
|
||||
const mergeBase = sha && upstreamCommit ? await readGit("merge-base", sha, upstreamCommit) : null;
|
||||
const counts =
|
||||
sha && upstreamCommit && mergeBase?.code === 0 && mergeBase.stdout.trim().length > 0
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-list", "--left-right", "--count", `${sha}...${upstreamCommit}`],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
sha && upstreamCommit && mergeBase
|
||||
? await readGit("rev-list", "--left-right", "--count", `${sha}...${upstreamCommit}`)
|
||||
: null;
|
||||
|
||||
const parseCounts = (raw: string): { ahead: number; behind: number } | null => {
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const ahead = Number.parseInt(parts[0] ?? "", 10);
|
||||
const behind = Number.parseInt(parts[1] ?? "", 10);
|
||||
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
|
||||
return null;
|
||||
}
|
||||
return { ahead, behind };
|
||||
};
|
||||
const parsed = counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
|
||||
const parsed = counts?.match(/^(\d+)\s+(\d+)$/u);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -364,8 +364,8 @@ async function checkGitUpdateStatus(params: {
|
||||
upstreamSha: upstreamCommit,
|
||||
commitAtMs,
|
||||
dirty,
|
||||
ahead: parsed?.ahead ?? null,
|
||||
behind: parsed?.behind ?? null,
|
||||
ahead: parsed ? Number(parsed[1]) : null,
|
||||
behind: parsed ? Number(parsed[2]) : null,
|
||||
fetchOk,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { trimLogTail } from "./restart-sentinel.js";
|
||||
import { DEV_BRANCH, resolveDevUpstreamRef } from "./update-channels.js";
|
||||
import { DEV_BRANCH, resolveDevUpstreamRefs } from "./update-channels.js";
|
||||
import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js";
|
||||
import {
|
||||
managerInstallArgs,
|
||||
@@ -210,11 +210,7 @@ async function resolveUpstreamCandidates(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
const trackingRevision = resolveDevUpstreamRef(
|
||||
params.needsCheckoutMain ? "HEAD" : DEV_BRANCH,
|
||||
true,
|
||||
);
|
||||
const upstreamRefs = [...(trackingRevision ? [trackingRevision] : []), ...remoteBranchRefs];
|
||||
const upstreamRefs = resolveDevUpstreamRefs(params.needsCheckoutMain, remoteBranchRefs);
|
||||
let upstreamSha: string | null = null;
|
||||
let selectedDevUpstream: string | null = null;
|
||||
let sawResolvableUpstreamRef = false;
|
||||
|
||||
Reference in New Issue
Block a user