fix(update): hide divergence when histories cannot be compared (#111946)

* fix(update): hide incomparable git divergence

Co-authored-by: luyifan <al3060388206@gmail.com>

* fix(update): handle dashed upstream refs

* fix(update): preserve older Git support

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ooiuuii
2026-07-25 17:24:04 +08:00
committed by GitHub
parent e9f2bd63a3
commit 39c6db44fb
2 changed files with 147 additions and 3 deletions

View File

@@ -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<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();
});
@@ -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 }),

View File

@@ -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;