diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 7bd78f77dd3f..3c9bb3fd8576 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -3,6 +3,7 @@ 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 { withTempDir } from "../test-helpers/temp-dir.js"; @@ -19,6 +20,25 @@ 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(); }); @@ -606,6 +626,112 @@ describe("formatGitInstallLabel", () => { }); describe("checkUpdateStatus", () => { + it("does not report divergence for unrelated histories", async () => { + await withTempDir({ 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 withTempDir({ 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 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 }; + }; + + const fullRoot = await cloneDivergedHistory("full"); + await expect(readDivergence(fullRoot)).resolves.toEqual({ ahead: 1, behind: 1 }); + 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 }); + + const truncatedRoot = await cloneDivergedHistory("shallow-depth-1", 1); + await expect(readDivergence(truncatedRoot)).resolves.toEqual({ + ahead: null, + behind: null, + }); + + const comparableRoot = await cloneDivergedHistory("shallow-depth-2", 2); + await expect(readDivergence(comparableRoot)).resolves.toEqual({ ahead: 1, behind: 1 }); + }); + }); + it("returns unknown install status when root is missing", async () => { await expect( checkUpdateStatus({ root: null, includeRegistry: false, timeoutMs: 1000 }), diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index abb0155f7604..6dadfb5e6065 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -249,10 +249,28 @@ async function checkGitUpdateStatus(params: { .catch(() => false) : null; - const counts = - upstream && upstream.length > 0 + // Freeze the post-fetch upstream for both graph queries. Resolve via @{upstream} rather than + // its display name so dashed remotes stay operands on older Git versions. Three-dot rev-list + // still counts disconnected or truncated histories, so require a visible common ancestor. + const upstreamCommitRes = + upstream && sha ? await runCommandWithTimeout( - ["git", "-C", root, "rev-list", "--left-right", "--count", `HEAD...${upstream}`], + ["git", "-C", root, "rev-parse", "--verify", "@{upstream}^{commit}"], + { timeoutMs }, + ).catch(() => null) + : null; + 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) + : 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) : null;