Skip to content

Commit 55a7871

Browse files
giodl73-reposteipete
authored andcommitted
fix(doctor): canonicalize git checkout detection
1 parent d9f73cf commit 55a7871

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
5454

5555
### Fixes
5656

57+
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
5758
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
5859
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
5960
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.

src/commands/doctor-update.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import fs from "node:fs/promises";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
4+
5+
const originalStdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
6+
7+
const mocks = vi.hoisted(() => ({
8+
note: vi.fn(),
9+
runCommandWithTimeout: vi.fn(),
10+
runGatewayUpdate: vi.fn(),
11+
}));
12+
13+
vi.mock("../process/exec.js", () => ({
14+
runCommandWithTimeout: mocks.runCommandWithTimeout,
15+
}));
16+
17+
vi.mock("../infra/update-runner.js", () => ({
18+
runGatewayUpdate: mocks.runGatewayUpdate,
19+
}));
20+
21+
vi.mock("../terminal/note.js", () => ({
22+
note: mocks.note,
23+
}));
24+
25+
async function runOffer(params?: {
26+
root?: string;
27+
confirm?: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
28+
}): Promise<Awaited<ReturnType<typeof maybeOfferUpdateBeforeDoctor>>> {
29+
const confirm = params?.confirm ?? vi.fn().mockResolvedValue(false);
30+
return await maybeOfferUpdateBeforeDoctor({
31+
runtime: {} as never,
32+
options: {},
33+
root: params?.root ?? "/repo/link",
34+
confirm,
35+
outro: vi.fn(),
36+
});
37+
}
38+
39+
beforeEach(async () => {
40+
mocks.note.mockReset();
41+
mocks.runCommandWithTimeout.mockReset();
42+
mocks.runGatewayUpdate.mockReset();
43+
Object.defineProperty(process.stdin, "isTTY", {
44+
configurable: true,
45+
value: true,
46+
});
47+
});
48+
49+
afterEach(() => {
50+
vi.restoreAllMocks();
51+
if (originalStdinIsTtyDescriptor) {
52+
Object.defineProperty(process.stdin, "isTTY", originalStdinIsTtyDescriptor);
53+
} else {
54+
delete (process.stdin as Partial<typeof process.stdin>).isTTY;
55+
}
56+
});
57+
58+
describe("maybeOfferUpdateBeforeDoctor", () => {
59+
it("treats a linked package root as a git checkout when realpaths match", async () => {
60+
const confirm = vi.fn().mockResolvedValue(false);
61+
vi.spyOn(fs, "realpath").mockImplementation(async (candidate) => {
62+
const value = String(candidate);
63+
if (value === "/repo/link" || value === "/repo/real") {
64+
return "/repo/real";
65+
}
66+
return value;
67+
});
68+
mocks.runCommandWithTimeout.mockResolvedValue({
69+
stdout: "/repo/real\n",
70+
stderr: "",
71+
code: 0,
72+
killed: false,
73+
signal: null,
74+
termination: "exit",
75+
noOutputTimedOut: false,
76+
});
77+
78+
await expect(runOffer({ root: "/repo/link", confirm })).resolves.toEqual({ updated: false });
79+
80+
expect(confirm).toHaveBeenCalledWith({
81+
message: "Update OpenClaw from git before running doctor?",
82+
initialValue: true,
83+
});
84+
expect(mocks.note).not.toHaveBeenCalledWith(
85+
expect.stringContaining("This install is not a git checkout."),
86+
"Update",
87+
);
88+
});
89+
90+
it("keeps package-manager guidance when git reports a different checkout", async () => {
91+
const confirm = vi.fn();
92+
vi.spyOn(fs, "realpath").mockImplementation(async (candidate) => String(candidate));
93+
mocks.runCommandWithTimeout.mockResolvedValue({
94+
stdout: "/repo/other\n",
95+
stderr: "",
96+
code: 0,
97+
killed: false,
98+
signal: null,
99+
termination: "exit",
100+
noOutputTimedOut: false,
101+
});
102+
103+
await expect(runOffer({ root: "/repo/link", confirm })).resolves.toEqual({ updated: false });
104+
105+
expect(confirm).not.toHaveBeenCalled();
106+
expect(mocks.note).toHaveBeenCalledWith(
107+
expect.stringContaining("This install is not a git checkout."),
108+
"Update",
109+
);
110+
});
111+
});

src/commands/doctor-update.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
13
import { formatCliCommand } from "../cli/command-format.js";
24
import { isTruthyEnvValue } from "../infra/env.js";
35
import { runGatewayUpdate } from "../infra/update-runner.js";
@@ -7,6 +9,10 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
79
import { note } from "../terminal/note.js";
810
import type { DoctorOptions } from "./doctor-prompter.js";
911

12+
async function resolveComparablePath(target: string): Promise<string> {
13+
return await fs.realpath(target).catch(() => path.resolve(target));
14+
}
15+
1016
async function detectOpenClawGitCheckout(root: string): Promise<"git" | "not-git" | "unknown"> {
1117
const res = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "--show-toplevel"], {
1218
timeoutMs: 5000,
@@ -22,7 +28,10 @@ async function detectOpenClawGitCheckout(root: string): Promise<"git" | "not-git
2228
}
2329
return "unknown";
2430
}
25-
return res.stdout.trim() === root ? "git" : "not-git";
31+
const gitRoot = res.stdout.trim();
32+
return (await resolveComparablePath(gitRoot)) === (await resolveComparablePath(root))
33+
? "git"
34+
: "not-git";
2635
}
2736

2837
export async function maybeOfferUpdateBeforeDoctor(params: {

0 commit comments

Comments
 (0)