File size: 12,944 Bytes
e249c6d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as gitExec from "../../infra/git-exec.js";
import * as execRunner from "../../process/exec-runner.js";
import * as commandExec from "../../process/exec.js";
import { estimateWorktreeCheckoutTransitionBytes, estimateWorktreeGitBytes } from "./capacity.js";
import { runGit } from "./git.js";
const execFileAsync = promisify(execFile);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" });
return stdout.trim();
}
describe("worktree Git size estimates", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
async function partialClone() {
const root = tempDirs.make("openclaw-partial-worktree-");
const source = path.join(root, "source");
const origin = path.join(root, "origin.git");
const clone = path.join(root, "clone");
await git(root, "init", "--template=", "-b", "main", source);
await git(source, "config", "user.name", "OpenClaw Test");
await git(source, "config", "user.email", "openclaw-test@example.invalid");
await git(source, "config", "commit.gpgSign", "false");
await fs.writeFile(path.join(source, "base.txt"), "base\n");
await git(source, "add", ".");
await git(source, "commit", "-m", "initial");
await git(root, "clone", "--bare", source, origin);
await git(origin, "config", "uploadpack.allowFilter", "true");
await git(origin, "config", "uploadpack.allowAnySHA1InWant", "true");
await git(root, "clone", "--filter=blob:none", pathToFileURL(origin).href, clone);
await fs.writeFile(path.join(source, "small.txt"), "small\n");
await fs.writeFile(path.join(source, "large.txt"), "x".repeat(5000));
await git(source, "add", ".");
await git(source, "commit", "-m", "add absent blobs");
await git(source, "push", origin, "main");
await git(clone, "fetch", "origin");
const commit = await git(clone, "rev-parse", "origin/main");
vi.stubEnv("GIT_NO_LAZY_FETCH", "1");
const missing = (
await git(
clone,
"rev-list",
"--objects",
"--missing=print",
"--no-object-names",
"--max-count=1",
commit,
)
)
.split("\n")
.filter((line) => line.startsWith("?"))
.map((line) => line.slice(1));
expect(missing).toHaveLength(2);
// The production executor inherits this guard, so old objectsize cannot hydrate the fixture.
await expect(runGit(clone, ["cat-file", "-e", missing[0]!])).resolves.toMatchObject({
code: 1,
});
return { root, source, origin, clone, commit, missing };
}
it("preserves admitted caller ownership config through text and buffered sizing without changing defaults", async () => {
const root = tempDirs.make("openclaw-capacity-caller-git-");
const repo = path.join(root, "repo");
await git(root, "init", "--template=", "-b", "main", repo);
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await git(repo, "config", "commit.gpgSign", "false");
await fs.writeFile(path.join(repo, "README.md"), "capacity\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initial");
vi.stubEnv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1");
vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1");
vi.stubEnv("GIT_CONFIG_GLOBAL", gitExec.gitNullConfigPath());
vi.stubEnv("GIT_CONFIG_COUNT", "1");
vi.stubEnv("GIT_CONFIG_KEY_0", "safe.directory");
vi.stubEnv("GIT_CONFIG_VALUE_0", await fs.realpath(repo));
// Managed Git intentionally replaces caller config with its hook/fsmonitor policy.
await expect(estimateWorktreeGitBytes(repo, "HEAD")).rejects.toThrow("dubious ownership");
const pending = estimateWorktreeGitBytes(repo, "HEAD", {
git: {
text: gitExec.executeGitCommandBytes,
buffered: gitExec.executeGitCommandBuffered,
},
});
// Later caller changes must not replace the environment captured at admission.
vi.stubEnv("GIT_CONFIG_VALUE_0", path.join(root, "not-the-repository"));
await expect(pending).resolves.toBe(4096);
await expect(estimateWorktreeGitBytes(repo, "HEAD")).rejects.toThrow("dubious ownership");
});
it.each(["remote promisor", "partialclone extension"])(
"prefetches missing blobs once from the %s and skips fetching local objects",
async (remoteConfig) => {
const { clone, commit, missing } = await partialClone();
if (remoteConfig === "partialclone extension") {
await git(clone, "config", "extensions.partialclone", "origin");
await git(clone, "config", "--unset", "remote.origin.promisor");
}
const commandSpy = vi.spyOn(gitExec, "executeGitCommandBytes");
const bufferedSpy = vi.spyOn(commandExec, "runCommandBuffered");
await expect(estimateWorktreeGitBytes(clone, commit)).resolves.toBe(16_384);
const fetches = commandSpy.mock.calls.filter(([, args]) => args[0] === "fetch");
expect(fetches.length).toBe(1);
const [fetchRoot, fetchArgs, fetchOptions] = fetches[0]!;
expect(fetchRoot).toBe(clone);
expect(fetchArgs).toEqual([
"fetch",
"origin",
"--no-tags",
"--no-write-fetch-head",
"--recurse-submodules=no",
"--stdin",
]);
expect(fetchOptions?.timeoutMs).toBe(300_000);
const input = fetchOptions?.input;
expect(
typeof input === "string"
? input
: input === undefined
? undefined
: Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8"),
).toBe(`${missing.join("\n")}\n`);
expect(
bufferedSpy.mock.calls.find(([argv]) => argv[0] === "git" && argv.includes("ls-tree"))?.[1]
?.env?.GIT_NO_LAZY_FETCH,
).toBe("1");
commandSpy.mockClear();
await expect(estimateWorktreeGitBytes(clone, commit)).resolves.toBe(16_384);
expect(commandSpy.mock.calls.filter(([, args]) => args[0] === "fetch").length).toBe(0);
},
);
it("hydrates both the source and destination when the overlay deletes missing source blobs", async () => {
const { root, source, origin } = await partialClone();
const base = await git(source, "rev-parse", "HEAD");
await git(source, "rm", "base.txt", "large.txt");
await git(source, "commit", "-m", "remove source blobs from overlay");
await git(source, "push", origin, "main");
const target = await git(source, "rev-parse", "HEAD");
const clone = path.join(root, "without-checkout");
await git(
root,
"clone",
"--filter=blob:none",
"--no-checkout",
pathToFileURL(origin).href,
clone,
);
const deleted = await git(source, "rev-parse", `${base}:large.txt`);
await expect(runGit(clone, ["cat-file", "-e", deleted])).resolves.toMatchObject({ code: 1 });
await expect(estimateWorktreeCheckoutTransitionBytes(clone, base, target)).resolves.toEqual({
targetBytes: 4096,
changedBytes: 0,
requiresFullCheckout: false,
});
await expect(runGit(clone, ["cat-file", "-e", deleted])).resolves.toMatchObject({ code: 0 });
await expect(estimateWorktreeCheckoutTransitionBytes(clone, target, target)).resolves.toEqual({
targetBytes: 4096,
changedBytes: 0,
requiresFullCheckout: false,
});
});
it("recovers a transient missing-object fetch before estimating the checkout", async () => {
const { clone, commit, missing } = await partialClone();
const run = execRunner.runCommandBuffersWithTimeout;
let attempts = 0;
vi.spyOn(execRunner, "runCommandBuffersWithTimeout").mockImplementation((argv, options) => {
if (argv.includes("fetch") && ++attempts === 1) {
return Promise.resolve({
stdout: Buffer.alloc(0),
stderr: Buffer.from("error: origin did not send all necessary objects\n"),
code: 1,
signal: null,
killed: false,
termination: "exit",
windowsEncoding: null,
});
}
return run(argv, options);
});
await expect(estimateWorktreeGitBytes(clone, commit)).resolves.toBe(16_384);
expect(attempts).toBe(2);
for (const object of missing) {
await expect(runGit(clone, ["cat-file", "-e", object])).resolves.toMatchObject({ code: 0 });
}
});
it("budgets each changed destination path with raw names and without credit for source deletions", async () => {
const { source } = await partialClone();
const base = await git(source, "rev-parse", "HEAD");
await git(source, "rm", "base.txt");
await fs.writeFile(path.join(source, "small.txt"), "y".repeat(5000));
await fs.writeFile(path.join(source, "copy-a.txt"), "same\n");
await fs.writeFile(path.join(source, "copy-b.txt"), "same\n");
const renamed = process.platform === "win32" ? "é space.txt" : "é space\nname.txt";
await git(source, "mv", "large.txt", renamed);
await git(source, "add", ".");
await git(source, "commit", "-m", "overlay with duplicate blobs and unusual path");
const target = await git(source, "rev-parse", "HEAD");
await expect(estimateWorktreeCheckoutTransitionBytes(source, base, target)).resolves.toEqual({
targetBytes: 24_576,
changedBytes: 24_576,
requiresFullCheckout: false,
});
});
it.each([".gitattributes", "nested/.gitattributes"])(
"budgets the full target when changing %s requires rematerializing unchanged files",
async (attributesPath) => {
const { source, commit } = await partialClone();
await fs.mkdir(path.dirname(path.join(source, attributesPath)), { recursive: true });
await fs.writeFile(path.join(source, attributesPath), "*.txt text eol=crlf\n");
await git(source, "add", attributesPath);
await git(source, "commit", "-m", "change checkout attributes");
const target = await git(source, "rev-parse", "HEAD");
await expect(
estimateWorktreeCheckoutTransitionBytes(source, commit, target),
).resolves.toEqual({
targetBytes: 20_480,
changedBytes: 20_480,
requiresFullCheckout: true,
});
await expect(
estimateWorktreeCheckoutTransitionBytes(source, target, commit),
).resolves.toEqual({
targetBytes: 16_384,
changedBytes: 16_384,
requiresFullCheckout: true,
});
},
);
it.each(["refs/replace/", "refs/size-replacements/"])(
"does not reuse byte totals across effective replacements in %s",
async (namespace) => {
const { source, commit } = await partialClone();
await expect(estimateWorktreeGitBytes(source, commit)).resolves.toBe(16_384);
vi.stubEnv("GIT_REPLACE_REF_BASE", namespace);
const original = await git(source, "rev-parse", `${commit}:base.txt`);
const replacementPath = path.join(source, "replacement.txt");
await fs.writeFile(replacementPath, "r".repeat(20_000));
const replacement = await git(source, "hash-object", "-w", replacementPath);
await git(source, "replace", original, replacement);
await expect(estimateWorktreeGitBytes(source, commit)).resolves.toBe(32_768);
await git(source, "replace", "-d", original);
await expect(estimateWorktreeGitBytes(source, commit)).resolves.toBe(16_384);
},
);
it("rejects newly missing objects even when the commit's byte total was already measured", async () => {
const { source, commit } = await partialClone();
await expect(estimateWorktreeGitBytes(source, commit)).resolves.toBe(16_384);
const blob = await git(source, "rev-parse", `${commit}:large.txt`);
await fs.unlink(path.join(source, ".git", "objects", blob.slice(0, 2), blob.slice(2)));
await expect(estimateWorktreeGitBytes(source, commit)).rejects.toThrow(
`Repository is missing 1 objects for ${commit}; fetch or repair the clone.`,
);
});
it("explains missing objects when no promisor remote can repair the clone", async () => {
const { clone, commit } = await partialClone();
await git(clone, "config", "--unset", "remote.origin.promisor");
const commandSpy = vi.spyOn(gitExec, "executeGitCommandBytes");
await expect(estimateWorktreeGitBytes(clone, commit)).rejects.toThrow(
`Repository is missing 2 objects for ${commit}; fetch or repair the clone.`,
);
expect(commandSpy.mock.calls.filter(([, args]) => args[0] === "fetch").length).toBe(0);
});
});
|