mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 04:11:39 +00:00
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:
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user