diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b0899aa68004..784d14bba0ec 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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 () => { diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 5683354f7586..f93e444f3825 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -48,6 +48,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise resolveStatusRegistryUpdateChannel({ diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index 83c4c344e33b..2c13f2ac30d2 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -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. */ diff --git a/src/infra/update-check-status.test.ts b/src/infra/update-check-status.test.ts new file mode 100644 index 000000000000..e70005eb95ca --- /dev/null +++ b/src/infra/update-check-status.test.ts @@ -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 { + 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 { + 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 { + 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); + }); + }); +}); diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 02f60bf89f44..44683e86598a 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -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 { - 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 { - 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 { - 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( diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 0fdff3fbca42..e71ea9c09577 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -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 { 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, }; } diff --git a/src/infra/update-runner-git-preflight.ts b/src/infra/update-runner-git-preflight.ts index 02723a3cd9b1..4357791d990f 100644 --- a/src/infra/update-runner-git-preflight.ts +++ b/src/infra/update-runner-git-preflight.ts @@ -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;