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:
Peter Steinberger
2026-08-16 08:33:55 -07:00
committed by GitHub
parent 61a3d3e7d9
commit 886e5c0490
7 changed files with 662 additions and 553 deletions
+6
View File
@@ -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 () => {
+1
View File
@@ -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({
+6 -6
View File
@@ -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. */
+569
View File
@@ -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);
});
});
});
+2 -465
View File
@@ -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
View File
@@ -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,
};
}
+2 -6
View File
@@ -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;