Download src/agents/worktrees/git.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 20.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/worktrees/git.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/worktrees/git.test.ts
-
curl -L -o git.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/worktrees/git.test.ts
20.4 kB
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; | |
| import { createDeferred } from "../../../test/helpers/promise.js"; | |
| import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; | |
| import * as execRunner from "../../process/exec-runner.js"; | |
| import * as processExec from "../../process/exec.js"; | |
| import type { SpawnResult } from "../../process/exec.js"; | |
| import { resolveWorktreeBase } from "./base-ref.js"; | |
| import { | |
| commandError, | |
| findGitCheckoutRoot, | |
| gitEnvironment, | |
| hasSelfContainedGitMetadata, | |
| insideGitCheckout, | |
| listGitWorktrees, | |
| requireGit, | |
| runGit, | |
| runGitBuffered, | |
| runGitBytes, | |
| } from "./git.js"; | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| vi.unstubAllEnvs(); | |
| }); | |
| describe("Git ref mutation ownership", () => { | |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); | |
| const snapshotRef = "refs/openclaw/snapshots/held"; | |
| const queuedRef = "refs/openclaw/snapshots/queued"; | |
| const transports = [ | |
| ["text", runGit], | |
| ["bytes", runGitBytes], | |
| ["buffered", runGitBuffered], | |
| ] as const; | |
| async function repository() { | |
| const root = tempDirs.make("openclaw-git-ref-"); | |
| await requireGit(root, ["init", "--quiet", "-b", "main"]); | |
| await requireGit(root, [ | |
| "-c", | |
| "user.name=OpenClaw Test", | |
| "-c", | |
| "user.email=test@localhost", | |
| "-c", | |
| "commit.gpgSign=false", | |
| "commit", | |
| "--allow-empty", | |
| "-m", | |
| "seed", | |
| ]); | |
| await requireGit(root, ["update-ref", snapshotRef, "HEAD"]); | |
| await requireGit(root, ["update-ref", queuedRef, "HEAD"]); | |
| return root; | |
| } | |
| it("rejects incomplete required stdout through the worktree wrapper", async () => { | |
| const root = await repository(); | |
| const input = Buffer.alloc(17 * 1024 * 1024, "x"); | |
| const oid = await requireGit(root, ["hash-object", "-w", "--stdin"], { input }); | |
| const outcome = await requireGit(root, ["cat-file", "blob", oid]).then( | |
| () => "returned incomplete output", | |
| (error: unknown) => (error instanceof Error ? error.message : String(error)), | |
| ); | |
| expect(outcome).toContain("output limit exceeded"); | |
| }); | |
| function holdSnapshotDeletion(failure?: Error, discoverySignal?: AbortSignal) { | |
| const started = createDeferred(); | |
| const release = createDeferred(); | |
| const discovered = createDeferred<{ code: number | null; termination: string }>(); | |
| const mutations: Array<{ cwd: string; args: string[] }> = []; | |
| const run = processExec.runCommandWithTimeout; | |
| const recordDiscovery = ( | |
| argv: string[], | |
| options: number | { signal?: AbortSignal }, | |
| result: { code: number | null; termination: string }, | |
| ) => { | |
| const commandIndex = argv.indexOf("-C") + 2; | |
| if ( | |
| discoverySignal && | |
| typeof options !== "number" && | |
| options.signal === discoverySignal && | |
| argv[commandIndex] === "rev-parse" && | |
| argv[commandIndex + 1] === "--git-common-dir" | |
| ) { | |
| discovered.resolve(result); | |
| } | |
| }; | |
| let held = false; | |
| vi.spyOn(processExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => { | |
| const args = argv.slice(3); | |
| if (args[0] === "update-ref" || (args[0] === "branch" && args[1] === "-D")) { | |
| mutations.push({ cwd: argv[2]!, args }); | |
| if (!held && args[0] === "update-ref" && args[2] === snapshotRef) { | |
| held = true; | |
| started.resolve(); | |
| await release.promise; | |
| if (failure) { | |
| throw failure; | |
| } | |
| } | |
| } | |
| const result = await run(argv, options); | |
| recordDiscovery(argv, options, result); | |
| return result; | |
| }); | |
| if (discoverySignal) { | |
| const runBytes = execRunner.runCommandBuffersWithTimeout; | |
| vi.spyOn(execRunner, "runCommandBuffersWithTimeout").mockImplementation( | |
| async (argv, options) => { | |
| const result = await runBytes(argv, options); | |
| recordDiscovery(argv, options, result); | |
| return result; | |
| }, | |
| ); | |
| const runBuffered = processExec.runCommandBuffered; | |
| vi.spyOn(processExec, "runCommandBuffered").mockImplementation(async (argv, options = {}) => { | |
| const result = await runBuffered(argv, options); | |
| recordDiscovery(argv, options, result); | |
| return result; | |
| }); | |
| } | |
| return { started, release, discovered, mutations }; | |
| } | |
| it("rejects cancelled discovery with exit code zero without deleting the requested ref", async () => { | |
| const root = await repository(); | |
| const commandSpy = vi.spyOn(processExec, "runCommandWithTimeout").mockResolvedValueOnce({ | |
| stdout: ".git\n", | |
| stderr: "", | |
| code: 0, | |
| signal: null, | |
| termination: "signal", | |
| killed: false, | |
| }); | |
| await expect(requireGit(root, ["update-ref", "-d", queuedRef])).rejects.toThrow( | |
| `git update-ref -d ${queuedRef} failed (terminated):\n.git`, | |
| ); | |
| expect(commandSpy.mock.calls.map(([argv]) => argv.slice(3))).toEqual([ | |
| ["rev-parse", "--git-common-dir"], | |
| ]); | |
| expect(await requireGit(root, ["show-ref", "--verify", queuedRef])).toContain(queuedRef); | |
| }); | |
| it.runIf(process.platform === "win32")( | |
| "normalizes MSYS common-directory output while preserving a literal revision mutation", | |
| async () => { | |
| const root = await repository(); | |
| const commonDir = await fs.realpath(path.join(root, ".git")); | |
| expect(commonDir).toMatch(/^[a-zA-Z]:[\\/]/u); | |
| const msysCommonDir = `/${commonDir[0]!.toLowerCase()}${commonDir.slice(2).replaceAll("\\", "/")}`; | |
| const run = processExec.runCommandWithTimeout; | |
| let discoveries = 0; | |
| vi.spyOn(processExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => { | |
| const result = await run(argv, options); | |
| if (argv[3] === "rev-parse" && argv[4] === "--git-common-dir") { | |
| discoveries += 1; | |
| return { ...result, stdout: `${msysCommonDir}\n` }; | |
| } | |
| return result; | |
| }); | |
| await requireGit(root, ["update-ref", queuedRef, "HEAD^{commit}"], { | |
| env: { MSYS: "winsymlinks:nativestrict", CYGWIN: "disable_pcon" }, | |
| }); | |
| expect(discoveries).toBe(1); | |
| expect(await requireGit(root, ["rev-parse", queuedRef])).toBe( | |
| await requireGit(root, ["rev-parse", "HEAD"]), | |
| ); | |
| }, | |
| ); | |
| it("serializes snapshot and branch deletes across checkout aliases without blocking other repositories or reads", async () => { | |
| const root = await repository(); | |
| const other = await repository(); | |
| const linked = path.join(root, "linked"); | |
| const alias = path.join(tempDirs.make("openclaw-git-alias-"), "repo"); | |
| await requireGit(root, ["worktree", "add", "--detach", linked, "HEAD"]); | |
| await requireGit(root, ["branch", "retired", "HEAD"]); | |
| await fs.symlink(root, alias, process.platform === "win32" ? "junction" : "dir"); | |
| const held = holdSnapshotDeletion(); | |
| const first = runGit(root, ["update-ref", "-d", snapshotRef]); | |
| const pending: Promise<unknown>[] = [first]; | |
| try { | |
| await held.started.promise; | |
| pending.push(requireGit(linked, ["branch", "-D", "retired"])); | |
| pending.push( | |
| requireGit(alias, ["update-ref", "--stdin"], { input: `delete ${queuedRef}\n` }), | |
| ); | |
| await requireGit(other, ["update-ref", "-d", queuedRef]); | |
| await expect(requireGit(linked, ["rev-parse", "HEAD"])).resolves.toMatch(/^[a-f0-9]+$/); | |
| expect(held.mutations.filter((call) => call.cwd !== other)).toEqual([ | |
| { cwd: root, args: ["update-ref", "-d", snapshotRef] }, | |
| ]); | |
| } finally { | |
| held.release.resolve(); | |
| await Promise.allSettled(pending); | |
| } | |
| await expect(first).resolves.toMatchObject({ code: 0, timeoutMs: 120_000 }); | |
| await Promise.all(pending); | |
| expect(await requireGit(root, ["for-each-ref", "--format=%(refname)"])).toBe("refs/heads/main"); | |
| expect(await requireGit(other, ["show-ref", "--verify", snapshotRef])).toContain(snapshotRef); | |
| }); | |
| it("rejects an interrupted explicit-base lookup even with exit code zero", async () => { | |
| const root = await repository(); | |
| vi.spyOn(processExec, "runCommandWithTimeout").mockResolvedValueOnce({ | |
| stdout: "refs/heads/-fixture\n", | |
| stderr: "", | |
| code: 0, | |
| signal: null, | |
| killed: false, | |
| termination: "signal", | |
| }); | |
| await expect(resolveWorktreeBase(root, "-fixture")).rejects.toThrow("terminated"); | |
| }); | |
| it.each(["fetch", "symbolic-ref"])( | |
| "does not select a remote base after an interrupted %s result", | |
| async (interrupted) => { | |
| const root = await repository(); | |
| const localHead = await requireGit(root, ["rev-parse", "HEAD"]); | |
| const run = processExec.runCommandWithTimeout; | |
| vi.spyOn(processExec, "runCommandWithTimeout").mockImplementation((argv, options) => { | |
| const command = argv[argv.indexOf("-C") + 2]; | |
| if (command !== "fetch" && command !== "symbolic-ref") { | |
| return run(argv, options); | |
| } | |
| return Promise.resolve({ | |
| stdout: command === "symbolic-ref" ? "origin/main\n" : "", | |
| stderr: "", | |
| code: 0, | |
| signal: null, | |
| killed: false, | |
| termination: command === interrupted ? "signal" : "exit", | |
| }); | |
| }); | |
| await expect(resolveWorktreeBase(root)).resolves.toEqual({ | |
| commit: localHead, | |
| gitOperand: "HEAD", | |
| recordRef: "HEAD", | |
| remote: false, | |
| }); | |
| }, | |
| ); | |
| it("serializes configured fetch pruning during managed-worktree base resolution", async () => { | |
| const root = await repository(); | |
| const origin = await repository(); | |
| const staleRef = "refs/remotes/origin/retired"; | |
| await requireGit(origin, ["branch", "retired", "HEAD"]); | |
| await requireGit(root, ["remote", "add", "origin", origin]); | |
| await requireGit(root, ["fetch", "origin"]); | |
| await requireGit(root, [ | |
| "symbolic-ref", | |
| "refs/remotes/origin/HEAD", | |
| "refs/remotes/origin/main", | |
| ]); | |
| await requireGit(root, ["config", "fetch.prune", "true"]); | |
| await requireGit(root, ["pack-refs", "--all"]); | |
| expect(await fs.readFile(path.join(root, ".git", "packed-refs"), "utf8")).toContain(staleRef); | |
| await requireGit(origin, ["branch", "-D", "retired"]); | |
| const originHead = await requireGit(origin, ["rev-parse", "HEAD"]); | |
| const controller = new AbortController(); | |
| const held = holdSnapshotDeletion(undefined, controller.signal); | |
| const pending: Promise<unknown>[] = [runGit(root, ["update-ref", "-d", snapshotRef])]; | |
| let resolved: ReturnType<typeof resolveWorktreeBase> | undefined; | |
| try { | |
| await held.started.promise; | |
| resolved = resolveWorktreeBase(root, undefined, controller.signal); | |
| pending.push(resolved); | |
| await expect( | |
| Promise.race([ | |
| held.discovered.promise, | |
| resolved.then(() => { | |
| throw new Error("fetch completed before its queued discovery barrier"); | |
| }), | |
| ]), | |
| ).resolves.toMatchObject({ code: 0, termination: "exit" }); | |
| await expect(requireGit(root, ["show-ref", "--verify", staleRef])).resolves.toContain( | |
| staleRef, | |
| ); | |
| expect(held.mutations).toEqual([{ cwd: root, args: ["update-ref", "-d", snapshotRef] }]); | |
| } finally { | |
| held.release.resolve(); | |
| await Promise.allSettled(pending); | |
| } | |
| await Promise.all(pending); | |
| await expect(resolved).resolves.toEqual({ | |
| commit: originHead, | |
| gitOperand: "origin/main", | |
| recordRef: "origin/main", | |
| remote: true, | |
| }); | |
| await expect( | |
| runGit(root, ["show-ref", "--verify", "--quiet", staleRef]), | |
| ).resolves.toMatchObject({ | |
| code: 1, | |
| }); | |
| }); | |
| it.each(transports)( | |
| "%s releases a rejected mutation and leaves a cancelled waiting branch deletion unexecuted", | |
| async (_transport, run) => { | |
| const root = await repository(); | |
| await requireGit(root, ["branch", "kept", "HEAD"]); | |
| const failure = new Error("Git executor unavailable"); | |
| const controller = new AbortController(); | |
| const held = holdSnapshotDeletion(failure, controller.signal); | |
| const rejected = expect(requireGit(root, ["update-ref", "-d", snapshotRef])).rejects.toBe( | |
| failure, | |
| ); | |
| const pending: Promise<unknown>[] = [rejected]; | |
| let cancelled: ReturnType<typeof run> | undefined; | |
| try { | |
| await held.started.promise; | |
| cancelled = run(root, ["branch", "-D", "kept"], { signal: controller.signal }); | |
| pending.push(cancelled, requireGit(root, ["update-ref", "-d", queuedRef])); | |
| // Abort only after this candidate's real discovery settles; a separate read | |
| // can finish first and accidentally cancel discovery instead of the writer. | |
| await expect( | |
| Promise.race([ | |
| held.discovered.promise, | |
| cancelled.then(() => { | |
| throw new Error("branch mutation completed before its queued discovery barrier"); | |
| }), | |
| ]), | |
| ).resolves.toMatchObject({ code: 0, termination: "exit" }); | |
| expect(held.mutations).toEqual([{ cwd: root, args: ["update-ref", "-d", snapshotRef] }]); | |
| controller.abort(); | |
| await expect(cancelled).resolves.toMatchObject({ | |
| code: null, | |
| termination: "signal", | |
| killed: false, | |
| }); | |
| } finally { | |
| held.release.resolve(); | |
| await Promise.allSettled(pending); | |
| } | |
| await Promise.all(pending); | |
| expect(await requireGit(root, ["show-ref", "--verify", "refs/heads/kept"])).toContain( | |
| "refs/heads/kept", | |
| ); | |
| expect((await runGit(root, ["show-ref", "--verify", "--quiet", queuedRef])).code).toBe(1); | |
| }, | |
| ); | |
| it.each(transports)( | |
| "%s preserves authority errors when the admitted callback also aborts", | |
| async (_transport, run) => { | |
| const root = await repository(); | |
| const controller = new AbortController(); | |
| const revoked = new Error("Git mutation authority revoked"); | |
| const beforeRun = vi.fn(() => { | |
| controller.abort(revoked); | |
| throw revoked; | |
| }); | |
| await expect( | |
| run(root, ["update-ref", "-d", queuedRef], { signal: controller.signal, beforeRun }), | |
| ).rejects.toBe(revoked); | |
| expect(beforeRun).toHaveBeenCalledOnce(); | |
| expect(await requireGit(root, ["show-ref", "--verify", queuedRef])).toContain(queuedRef); | |
| }, | |
| ); | |
| it("keeps discovery and queued mutation in the captured Git environment", async () => { | |
| vi.stubEnv("GIT_COMMON_DIR", undefined); | |
| const root = await repository(); | |
| const other = await repository(); | |
| const held = holdSnapshotDeletion(); | |
| const first = runGit(root, ["update-ref", "-d", snapshotRef]); | |
| const pending: Promise<unknown>[] = [first]; | |
| try { | |
| await held.started.promise; | |
| pending.push(requireGit(root, ["update-ref", "-d", queuedRef])); | |
| // A newly introduced authority variable must not redirect a queued command | |
| // after its repository identity and inherited environment were captured. | |
| vi.stubEnv("GIT_COMMON_DIR", path.join(other, ".git")); | |
| } finally { | |
| held.release.resolve(); | |
| await Promise.allSettled(pending); | |
| vi.stubEnv("GIT_COMMON_DIR", undefined); | |
| } | |
| await Promise.all(pending); | |
| expect((await runGit(root, ["show-ref", "--verify", "--quiet", queuedRef])).code).toBe(1); | |
| expect(await requireGit(other, ["show-ref", "--verify", queuedRef])).toContain(queuedRef); | |
| }); | |
| }); | |
| describe("Git execution environment", () => { | |
| it("preserves literal commit revisions only for Windows worktree Git", () => { | |
| expect( | |
| gitEnvironment( | |
| { | |
| MSYS: "winsymlinks:nativestrict", | |
| CYGWIN: "disable_pcon", | |
| }, | |
| ["rev-parse", "--verify", "HEAD^{commit}"], | |
| "win32", | |
| ), | |
| ).toMatchObject({ | |
| MSYS: "winsymlinks:nativestrict noglob", | |
| CYGWIN: "disable_pcon noglob", | |
| }); | |
| expect(gitEnvironment({ MSYS: "winsymlinks:nativestrict" }, ["status"], "win32").MSYS).toBe( | |
| "winsymlinks:nativestrict", | |
| ); | |
| }); | |
| it.each([ | |
| ["noglob winsymlinks:native", "noglob winsymlinks:native noglob"], | |
| ["noglob glob:ignorecase", "noglob glob:ignorecase noglob"], | |
| ["winsymlinks:native noglob", "winsymlinks:native noglob"], | |
| ])("keeps noglob final for %s", (value, expected) => { | |
| expect(gitEnvironment({ MSYS: value }, ["rev-parse", "HEAD^{commit}"], "win32").MSYS).toBe( | |
| expected, | |
| ); | |
| }); | |
| it("merges inherited Windows runtime options before preserving revisions", () => { | |
| expect( | |
| gitEnvironment( | |
| { GIT_INDEX_FILE: "snapshot.index", msys: "winsymlinks:native", CYGWIN: undefined }, | |
| ["rev-parse", "HEAD^{commit}"], | |
| "win32", | |
| { MSYS: "winsymlinks:nativestrict", CYGWIN: "disable_pcon" }, | |
| ), | |
| ).toMatchObject({ | |
| GIT_INDEX_FILE: "snapshot.index", | |
| MSYS: "winsymlinks:native noglob", | |
| CYGWIN: "noglob", | |
| }); | |
| expect( | |
| gitEnvironment({ MSYS: "winsymlinks:native" }, ["rev-parse", "HEAD^{commit}"], "linux").MSYS, | |
| ).toBe("winsymlinks:native"); | |
| }); | |
| }); | |
| describe("Git checkout discovery", () => { | |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); | |
| it("reports a real Git failure with execution metadata through the worktree wrapper", async () => { | |
| const root = tempDirs.make("openclaw-git-error-"); | |
| const result = await runGit(path.join(root, "missing"), ["status"]); | |
| expectTypeOf(result).toMatchTypeOf<SpawnResult>(); | |
| expect(result.timeoutMs).toBe(120_000); | |
| expect(result.code).toBe(128); | |
| expect(result).toMatchObject({ termination: "exit", signal: null }); | |
| const message = commandError("git status", result).message; | |
| expect(message).toContain("git status failed (exit code 128)"); | |
| expect(message).toContain("fatal:"); | |
| expect(message).not.toMatch(/timeout|timed out/i); | |
| }); | |
| it("returns the nearest checkout root for nested paths", async () => { | |
| const root = tempDirs.make("openclaw-git-root-"); | |
| const nested = path.join(root, "packages", "nested"); | |
| await fs.mkdir(path.join(root, ".git")); | |
| await fs.mkdir(nested, { recursive: true }); | |
| expect(findGitCheckoutRoot(nested)).toBe(root); | |
| expect(insideGitCheckout(nested)).toBe(true); | |
| }); | |
| it("returns null outside a checkout", async () => { | |
| const root = tempDirs.make("openclaw-no-git-root-"); | |
| expect(findGitCheckoutRoot(root)).toBeNull(); | |
| expect(insideGitCheckout(root)).toBe(false); | |
| }); | |
| it("distinguishes contained metadata from linked checkout pointers", async () => { | |
| const root = tempDirs.make("openclaw-git-metadata-"); | |
| await fs.mkdir(path.join(root, ".git")); | |
| await expect(hasSelfContainedGitMetadata(root)).resolves.toBe(true); | |
| await fs.rm(path.join(root, ".git"), { recursive: true }); | |
| await fs.writeFile(path.join(root, ".git"), "gitdir: /outside/worktrees/card\n", "utf8"); | |
| await expect(hasSelfContainedGitMetadata(root)).resolves.toBe(false); | |
| }); | |
| it("parses existing linked worktree paths and lock reasons", async () => { | |
| const root = tempDirs.make("openclaw-git-worktree-list-"); | |
| const repo = path.join(root, "repo"); | |
| const linked = path.join(root, "linked"); | |
| expect((await runGit(root, ["init", "-b", "main", repo])).code).toBe(0); | |
| expect((await runGit(repo, ["config", "user.name", "OpenClaw Test"])).code).toBe(0); | |
| expect( | |
| (await runGit(repo, ["config", "user.email", "openclaw-test@example.invalid"])).code, | |
| ).toBe(0); | |
| await fs.writeFile(path.join(repo, "README.md"), "base\n"); | |
| expect((await runGit(repo, ["add", "README.md"])).code).toBe(0); | |
| expect((await runGit(repo, ["commit", "-m", "initial"])).code).toBe(0); | |
| expect((await runGit(repo, ["worktree", "add", "-b", "linked", linked, "HEAD"])).code).toBe(0); | |
| expect( | |
| (await runGit(repo, ["worktree", "lock", "--reason", "held by test", linked])).code, | |
| ).toBe(0); | |
| // These fixtures exist; compare identity without imposing Git's separator spelling. | |
| const worktrees = await listGitWorktrees(repo); | |
| for (const entry of worktrees) { | |
| entry.path = await fs.realpath(entry.path); | |
| } | |
| expect(worktrees).toContainEqual({ | |
| path: await fs.realpath(linked), | |
| lockedReason: "held by test", | |
| }); | |
| }); | |
| }); | |