diff --git a/README.md b/README.md index cdd6752..46d47cf 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,8 @@ Configure shell commands in `createGrove({ hooks })`. Hook cwd is the worktree p | `postRelease` | After lease cleanup | | `preDestroy` | Before worktree removal | +Crash recovery may run `postCreate` more than once. Keep that hook idempotent; reused physical slots do not run it. + Lease hooks receive: `GROVE_LEASE_ID`, `GROVE_SLOT_NAME`, `GROVE_BRANCH`, `GROVE_REPO_ROOT`, `GROVE_WORKTREE_PATH`. Set `onHookFailure: "fail"` to throw `HOOK_FAILED` on hook errors. diff --git a/packages/grove/src/config.ts b/packages/grove/src/config.ts index 7bd1bc9..39ae92b 100644 --- a/packages/grove/src/config.ts +++ b/packages/grove/src/config.ts @@ -1,6 +1,6 @@ -import { join, basename, isAbsolute } from "node:path"; +import { join, basename, isAbsolute, resolve } from "node:path"; import { homedir } from "node:os"; -import { getRemoteUrl, shortHash } from "./git/index.js"; +import { shortHash } from "./git/index.js"; function expandEnv(str: string): string { return str.replace(/\$(?:{([A-Za-z_][A-Za-z0-9_]*)}|([A-Za-z_][A-Za-z0-9_]*))/g, (_, n1, n2) => { @@ -10,13 +10,9 @@ function expandEnv(str: string): string { } export async function resolveGroveDir(repoRoot: string, root?: string): Promise { - let hashInput = repoRoot; - try { - hashInput = await getRemoteUrl(repoRoot); - } catch {} - - const repoName = basename(repoRoot); - const hash = shortHash(hashInput); + const normalizedRepoRoot = resolve(repoRoot); + const repoName = basename(normalizedRepoRoot); + const hash = shortHash(normalizedRepoRoot); const poolName = `${repoName}-${hash}`; if (!root) { diff --git a/packages/grove/src/lease-acquire.ts b/packages/grove/src/lease-acquire.ts index a44e9b3..8d1defc 100644 --- a/packages/grove/src/lease-acquire.ts +++ b/packages/grove/src/lease-acquire.ts @@ -85,6 +85,27 @@ export async function finalizeLeaseCheckout( return enrichLeaseReadOnly(lease); } +async function persistPostCreatePending( + poolDir: string, + repoRoot: string, + leaseId: string, + postCreatePending: boolean, +): Promise { + await withStateLock(poolDir, async () => { + const state = await loadPoolState(poolDir, repoRoot); + const lease = findLease(state, leaseId); + if (!lease) { + throw new LeaseNotFoundError(`Lease ${leaseId} not found after postCreate`); + } + + const leaseIndex = state.leases.findIndex((entry) => entry.leaseId === leaseId); + state.leases[leaseIndex] = postCreatePending + ? transitionLease(lease, { type: "REPAIR_RESUME_ACQUIRE", postCreatePending: true })! + : transitionLease(lease, { type: "ACQUIRE_POST_CREATE_COMPLETE" })!; + await savePoolState(poolDir, state); + }); +} + export async function quarantineFailedAcquire( poolDir: string, repoRoot: string, @@ -127,7 +148,6 @@ export async function acquireLease( const pendingTarget = await buildAcquireTarget(options, repoRoot); const now = new Date().toISOString(); - const pendingAcquire = buildPendingAcquire(pendingTarget, now); let targetWtPath = ""; let isNewSlot = false; @@ -184,6 +204,7 @@ export async function acquireLease( } const { slot, isNew } = await findOrAllocateSlot(state, poolDir, config); + const pendingAcquire = buildPendingAcquire(pendingTarget, now, isNew); const reservedSlot = transitionSlot(slot, { type: "RESERVE_FOR_LEASE" }, now)!; const slotIndex = state.slots.findIndex((entry) => entry.slotName === slot.slotName); @@ -238,6 +259,7 @@ export async function acquireLease( await quarantineFailedAcquire(poolDir, repoRoot, leaseIdForCheckout, reason, "postCreate"); throw err; } + await persistPostCreatePending(poolDir, repoRoot, leaseIdForCheckout, false); } try { @@ -286,14 +308,21 @@ export async function resumeAcquireLease( if (!lease.pendingAcquire) { throw new RepairNotAvailableError("resume-acquire requires pendingAcquire"); } - if (lease.state !== "quarantined") { + if (lease.state !== "quarantined" && lease.state !== "preparing") { throw new RepairNotAvailableError( - `resume-acquire requires quarantined lease, got ${lease.state}`, + `resume-acquire requires quarantined or preparing lease, got ${lease.state}`, ); } + const postCreatePending = + !existsSync(lease.path) || + (lease.pendingAcquire.postCreatePending ?? lease.diagnostics?.failedPhase === "postCreate"); + const leaseIndex = state.leases.findIndex((entry) => entry.leaseId === leaseId); - state.leases[leaseIndex] = transitionLease(lease, { type: "REPAIR_RESUME_ACQUIRE" })!; + state.leases[leaseIndex] = transitionLease(lease, { + type: "REPAIR_RESUME_ACQUIRE", + postCreatePending, + })!; const slot = findSlot(state, lease.slotName); if (slot && slot.state === "quarantined") { @@ -304,7 +333,7 @@ export async function resumeAcquireLease( await savePoolState(poolDir, state); wtPath = lease.path; slotName = lease.slotName; - runPostCreate = lease.diagnostics?.failedPhase === "postCreate"; + runPostCreate = postCreatePending; pendingTarget = lease.pendingAcquire.target; }); @@ -312,6 +341,7 @@ export async function resumeAcquireLease( let lease: GroveLease; if (!existsSync(wtPath)) { try { + await persistPostCreatePending(poolDir, repoRoot, leaseId, true); const state = await loadPoolState(poolDir, repoRoot, { heal: false }); const slot = findSlot(state, slotName); if (!slot) { @@ -333,6 +363,7 @@ export async function resumeAcquireLease( await quarantineFailedAcquire(poolDir, repoRoot, leaseId, reason, "postCreate"); throw err; } + await persistPostCreatePending(poolDir, repoRoot, leaseId, false); } try { diff --git a/packages/grove/src/lease-destroy.ts b/packages/grove/src/lease-destroy.ts index e1b87ce..84665b3 100644 --- a/packages/grove/src/lease-destroy.ts +++ b/packages/grove/src/lease-destroy.ts @@ -1,4 +1,4 @@ -import { dirname } from "node:path"; +import { basename, dirname, normalize } from "node:path"; import { rm } from "node:fs/promises"; import type { GroveConfig, GroveLeaseRecord, GroveSlot } from "./schemas.js"; import type { DestroyLeaseOptions } from "./types.js"; @@ -7,7 +7,12 @@ import { withStateLock } from "./lock.js"; import { assertPathWithinPool } from "./path-boundary.js"; import { isWorktreeInUse } from "./process/detect.js"; import { assertWorktreeSafeForCleanup } from "./process/cleanup-safety.js"; -import { InvalidInputError, LeaseBusyError, LeaseNotFoundError } from "./errors.js"; +import { + InvalidInputError, + LeaseBusyError, + LeaseNotFoundError, + PathOutsidePoolError, +} from "./errors.js"; import { buildLeaseHookEnv, recordToGroveLease } from "./lease-view.js"; import { findLease, @@ -39,6 +44,19 @@ function assertDeleteBranchNotRequested(options?: DestroyLeaseOptions): void { } } +function destroySlotDirectory(poolDir: string, wtPath: string, slotName: string): string { + const normalizedPool = normalize(poolDir); + const slotDir = dirname(normalize(wtPath)); + if ( + slotDir === normalizedPool || + dirname(slotDir) !== normalizedPool || + basename(slotDir) !== slotName + ) { + throw new PathOutsidePoolError("Worktree is not inside its owned pool slot directory"); + } + return slotDir; +} + function assertLeaseDestroyable( lease: { leaseId: string; state: string }, resuming: boolean, @@ -254,8 +272,9 @@ async function completeDestroy( }); await assertPathWithinPool(poolDir, wtPath); + const slotDir = destroySlotDirectory(poolDir, wtPath, slot.slotName); await removeWorktree(config.repoRoot, wtPath); - await rm(dirname(wtPath), { recursive: true, force: true }); + await rm(slotDir, { recursive: true, force: true }); } catch (err) { const reason = err instanceof Error ? err.message : "destroy failed"; await quarantineFailedDestroy(poolDir, config.repoRoot, context.leaseId, reason); diff --git a/packages/grove/src/lease-repair.ts b/packages/grove/src/lease-repair.ts index fab7b44..0748773 100644 --- a/packages/grove/src/lease-repair.ts +++ b/packages/grove/src/lease-repair.ts @@ -1,6 +1,5 @@ import type { GroveConfig } from "./schemas.js"; import type { - DestroyLeaseOptions, GroveLease, ReleaseResult, RepairLeaseOptions, @@ -79,9 +78,8 @@ async function repairForceDestroy( } }); - const destroyOptions: DestroyLeaseOptions = { force: true }; const destroyHooks = hooks.preDestroy ? { preDestroy: hooks.preDestroy } : {}; - await destroyLease(poolDir, config, leaseId, destroyOptions, destroyHooks); + await destroyLease(poolDir, config, leaseId, options, destroyHooks); return { status: "destroyed", leaseId }; } diff --git a/packages/grove/src/process/detect.ts b/packages/grove/src/process/detect.ts index 63cdf35..959368f 100644 --- a/packages/grove/src/process/detect.ts +++ b/packages/grove/src/process/detect.ts @@ -130,8 +130,11 @@ export async function findInWorktree(worktreePath: string): Promise; export const PendingAcquireSchema = z.object({ target: GroveLeaseTargetSchema, startedAt: z.string(), + postCreatePending: z.boolean().optional(), }); export type PendingAcquire = z.infer; diff --git a/packages/grove/src/target.ts b/packages/grove/src/target.ts index 3ad0daf..671e0ca 100644 --- a/packages/grove/src/target.ts +++ b/packages/grove/src/target.ts @@ -74,8 +74,12 @@ export async function buildAcquireTarget( } } -export function buildPendingAcquire(target: GroveLeaseTarget, startedAt: string): PendingAcquire { - return { target, startedAt }; +export function buildPendingAcquire( + target: GroveLeaseTarget, + startedAt: string, + postCreatePending: boolean, +): PendingAcquire { + return { target, startedAt, postCreatePending }; } export function finalizeBranchTarget(target: GroveLeaseTarget, headSha: string): GroveLeaseTarget { diff --git a/packages/grove/src/transitions.ts b/packages/grove/src/transitions.ts index 6df0426..4e9f7bb 100644 --- a/packages/grove/src/transitions.ts +++ b/packages/grove/src/transitions.ts @@ -16,6 +16,7 @@ import { export type LeaseEvent = | { type: "ACQUIRE_COMPLETE"; target: GroveLeaseTarget; headSha: string } | { type: "ACQUIRE_FAILED"; reason: string; failedPhase?: GroveFailedPhase } + | { type: "ACQUIRE_POST_CREATE_COMPLETE" } | { type: "RELEASE_START"; cleanup: LeaseFirstCleanupIntent } | { type: "RELEASE_PRESERVE_COMPLETE" } | { type: "RELEASE_RESET_COMPLETE" } @@ -24,7 +25,7 @@ export type LeaseEvent = | { type: "DESTROY_START" } | { type: "DESTROY_COMPLETE" } | { type: "DESTROY_FAILED"; reason: string } - | { type: "REPAIR_RESUME_ACQUIRE" } + | { type: "REPAIR_RESUME_ACQUIRE"; postCreatePending?: boolean } | { type: "REPAIR_RESUME_CLEANUP" }; export type SlotEvent = @@ -81,6 +82,21 @@ export function transitionLease( diagnostics: quarantineDiagnostics(lease, event.reason, event.failedPhase), }; } + case "ACQUIRE_POST_CREATE_COMPLETE": { + if ( + lease.state !== "preparing" || + !lease.pendingAcquire || + lease.pendingAcquire.postCreatePending !== true + ) { + throw new InvalidTransitionError( + `ACQUIRE_POST_CREATE_COMPLETE invalid from lease state ${lease.state}`, + ); + } + return { + ...base, + pendingAcquire: { ...lease.pendingAcquire, postCreatePending: false }, + }; + } case "RELEASE_START": { if (lease.state !== "leased") { throw new InvalidTransitionError(`RELEASE_START invalid from lease state ${lease.state}`); @@ -172,7 +188,7 @@ export function transitionLease( }; } case "REPAIR_RESUME_ACQUIRE": { - if (lease.state !== "quarantined") { + if (lease.state !== "quarantined" && lease.state !== "preparing") { throw new InvalidTransitionError( `REPAIR_RESUME_ACQUIRE invalid from lease state ${lease.state}`, ); @@ -180,7 +196,18 @@ export function transitionLease( if (!lease.pendingAcquire) { throw new RepairNotAvailableError("resume-acquire requires pendingAcquire"); } - return { ...base, state: "preparing" }; + return { + ...base, + state: "preparing", + ...(event.postCreatePending === undefined + ? {} + : { + pendingAcquire: { + ...lease.pendingAcquire, + postCreatePending: event.postCreatePending, + }, + }), + }; } case "REPAIR_RESUME_CLEANUP": { if (lease.state !== "quarantined") { diff --git a/packages/grove/test/config.test.ts b/packages/grove/test/config.test.ts index 415955a..fb680ff 100644 --- a/packages/grove/test/config.test.ts +++ b/packages/grove/test/config.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { resolveGroveDir } from "../src/config.js"; -import { setupPathFixture } from "./helpers/git-repo.js"; -import { rm } from "node:fs/promises"; +import { setupPathFixture, setupRepo } from "./helpers/git-repo.js"; +import { mkdir, rm } from "node:fs/promises"; import { join, basename } from "node:path"; import { homedir } from "node:os"; +import { execa } from "execa"; describe("Config", () => { let tmpDirs: string[] = []; @@ -79,4 +80,15 @@ describe("Config", () => { delete process.env["TEST_GROVE_ROOT"]; delete process.env["var2"]; }); + + it("uses distinct pools for same-named clones of one remote", async () => { + const { repoDir, remoteDir, tmpDir } = await setupRepo(); + tmpDirs.push(tmpDir); + const secondParent = join(tmpDir, "second"); + const secondRepoDir = join(secondParent, basename(repoDir)); + await mkdir(secondParent, { recursive: true }); + await execa("git", ["clone", remoteDir, secondRepoDir]); + + await expect(resolveGroveDir(repoDir)).resolves.not.toBe(await resolveGroveDir(secondRepoDir)); + }); }); diff --git a/packages/grove/test/lease-destroy.integration.test.ts b/packages/grove/test/lease-destroy.integration.test.ts index f5f3081..d190663 100644 --- a/packages/grove/test/lease-destroy.integration.test.ts +++ b/packages/grove/test/lease-destroy.integration.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { createTestGrove } from "./helpers/test-grove.js"; import { setupRepo } from "./helpers/git-repo.js"; import { registerLeaseIntegrationCleanup } from "./helpers/lease-integration.js"; +import { readLeaseFirstState, writeLeaseFirstState } from "../src/state-v1.js"; describe("lease destroy integration", () => { const cleanup = registerLeaseIntegrationCleanup(); @@ -57,6 +58,36 @@ describe("lease destroy integration", () => { expect(quarantined?.state).toBe("quarantined"); }); + it("destroy rejects a worktree whose parent is the pool root", async () => { + const { repoDir, tmpDir, groveDir } = await setupRepo(); + cleanup.tmpDirs.push(tmpDir); + + const grove = await createTestGrove({ repoRoot: repoDir, groveRoot: groveDir }); + const lease = await grove.acquire({ + leaseId: "shallow-layout-lease", + mode: "branch", + branch: "shallow-layout-branch", + createBranch: { from: "main", ifExists: "fail" }, + }); + + const shallowPath = join(grove.poolDir, "repo"); + await execa("git", ["worktree", "move", lease.path, shallowPath], { cwd: repoDir }); + const state = await readLeaseFirstState(grove.poolDir); + state.slots[0]!.path = shallowPath; + state.leases[0]!.path = shallowPath; + await writeLeaseFirstState(grove.poolDir, state); + + const sentinelPath = join(grove.poolDir, "sentinel.txt"); + await writeFile(sentinelPath, "keep"); + + await expect(grove.destroy(lease.leaseId, { force: true })).rejects.toMatchObject({ + code: "PATH_OUTSIDE_POOL", + }); + expect(existsSync(sentinelPath)).toBe(true); + expect(existsSync(shallowPath)).toBe(true); + expect(await grove.inspect(lease.leaseId)).toMatchObject({ state: "quarantined" }); + }); + it("idempotent destroy resumes an in-progress destroying lease", async () => { const { repoDir, tmpDir, groveDir } = await setupRepo(); cleanup.tmpDirs.push(tmpDir); diff --git a/packages/grove/test/lease-hooks.integration.test.ts b/packages/grove/test/lease-hooks.integration.test.ts index 6a3d198..40c7341 100644 --- a/packages/grove/test/lease-hooks.integration.test.ts +++ b/packages/grove/test/lease-hooks.integration.test.ts @@ -64,7 +64,7 @@ describe("lease hooks integration", () => { expect(lease).toMatchObject({ leaseId: "post-create-fail", state: "quarantined", - pendingAcquire: expect.anything(), + pendingAcquire: expect.objectContaining({ postCreatePending: true }), diagnostics: { failedPhase: "postCreate" }, }); }); diff --git a/packages/grove/test/lease-release.integration.test.ts b/packages/grove/test/lease-release.integration.test.ts index 4881100..5e21145 100644 --- a/packages/grove/test/lease-release.integration.test.ts +++ b/packages/grove/test/lease-release.integration.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { createTestGrove } from "./helpers/test-grove.js"; import { setupRepo } from "./helpers/git-repo.js"; import { registerLeaseIntegrationCleanup } from "./helpers/lease-integration.js"; +import { findInWorktree } from "../src/process/detect.js"; describe("lease release integration", () => { const cleanup = registerLeaseIntegrationCleanup(); @@ -44,6 +45,48 @@ describe("lease release integration", () => { } }); + it("treats an unavailable macOS process scan as unverified", async () => { + const { repoDir, tmpDir, groveDir } = await setupRepo(); + cleanup.tmpDirs.push(tmpDir); + + const grove = await createTestGrove({ repoRoot: repoDir, groveRoot: groveDir }); + const lease = await grove.acquire({ + leaseId: "unverified-scan", + mode: "branch", + branch: "unverified-scan-branch", + createBranch: { from: "main", ifExists: "fail" }, + }); + await grove.release(lease.leaseId, { cleanup: "preserve" }); + + const originalPlatform = process.platform; + const originalPath = process.env.PATH; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + process.env.PATH = join(tmpDir, "missing-bin"); + + try { + await expect(findInWorktree(lease.path)).resolves.toEqual({ + processes: [], + unverified: true, + }); + await expect( + grove.release(lease.leaseId, { cleanup: "reset", resetTo: "main" }), + ).rejects.toMatchObject({ code: "UNSAFE_CLEANUP" }); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + + expect(await grove.inspect(lease.leaseId)).toMatchObject({ state: "leased" }); + expect(existsSync(lease.path)).toBe(true); + }); + it("preserve release keeps dirty files and returns preserved lease", async () => { const { repoDir, tmpDir, groveDir } = await setupRepo(); cleanup.tmpDirs.push(tmpDir); diff --git a/packages/grove/test/lease-repair.integration.test.ts b/packages/grove/test/lease-repair.integration.test.ts index ec1a918..0df3c39 100644 --- a/packages/grove/test/lease-repair.integration.test.ts +++ b/packages/grove/test/lease-repair.integration.test.ts @@ -6,6 +6,22 @@ import { join } from "node:path"; import { createTestGrove } from "./helpers/test-grove.js"; import { setupRepo } from "./helpers/git-repo.js"; import { registerLeaseIntegrationCleanup } from "./helpers/lease-integration.js"; +import { repairLease } from "../src/lease-repair.js"; + +async function markLeasePreparing(statePath: string, postCreatePending: boolean): Promise { + const state = JSON.parse(await readFile(statePath, "utf8")); + const lease = state.leases[0]; + lease.pendingAcquire = { + target: lease.target, + startedAt: lease.updatedAt, + postCreatePending, + }; + lease.state = "preparing"; + delete lease.target; + delete lease.acquiredHeadSha; + delete lease.currentHeadSha; + await writeFile(statePath, JSON.stringify(state)); +} describe("lease repair integration", () => { const cleanup = registerLeaseIntegrationCleanup(); @@ -259,6 +275,129 @@ describe("lease repair integration", () => { expect(existsSync(lease.path)).toBe(false); }); + it("repair force-destroy preserves caller force intent after preDestroy", async () => { + const { repoDir, tmpDir, groveDir } = await setupRepo(); + cleanup.tmpDirs.push(tmpDir); + + const grove = await createTestGrove({ repoRoot: repoDir, groveRoot: groveDir }); + const lease = await grove.acquire({ + leaseId: "force-intent-lease", + mode: "branch", + branch: "force-intent-branch", + createBranch: { from: "main", ifExists: "fail" }, + }); + await grove.release(lease.leaseId, { cleanup: "quarantine" }); + + let stopChild = async (): Promise => {}; + try { + await expect( + repairLease( + grove.poolDir, + { repoRoot: repoDir }, + { leaseId: lease.leaseId, action: "force-destroy" }, + { + preDestroy: async (wtPath) => { + const child = execa("node", ["-e", "setInterval(() => {}, 1000)"], { + cwd: wtPath, + }); + stopChild = async () => { + child.kill(); + await child.catch(() => {}); + }; + await new Promise((resolve) => setTimeout(resolve, 500)); + }, + }, + ), + ).rejects.toMatchObject({ code: "UNSAFE_CLEANUP" }); + + expect(await grove.inspect(lease.leaseId)).toMatchObject({ state: "quarantined" }); + expect(existsSync(lease.path)).toBe(true); + + const result = await grove.repair({ + leaseId: lease.leaseId, + action: "force-destroy", + force: true, + }); + expect(result).toEqual({ status: "destroyed", leaseId: lease.leaseId }); + expect(await grove.inspect(lease.leaseId)).toBeNull(); + expect(existsSync(lease.path)).toBe(false); + } finally { + await stopChild(); + } + }); + + it("repair resume-acquire completes a preparing lease without replaying postCreate", async () => { + const { repoDir, tmpDir, groveDir } = await setupRepo(); + cleanup.tmpDirs.push(tmpDir); + + const grove = await createTestGrove({ repoRoot: repoDir, groveRoot: groveDir }); + const firstLease = await grove.acquire({ + leaseId: "preparing-slot-source", + mode: "detached", + ref: "main", + }); + await grove.release(firstLease.leaseId, { + cleanup: "reset", + resetTo: "main", + force: true, + }); + + const lease = await grove.acquire({ + leaseId: "preparing-resume", + mode: "branch", + branch: "preparing-resume-branch", + createBranch: { from: "main", ifExists: "fail" }, + }); + expect(lease.slotName).toBe(firstLease.slotName); + await markLeasePreparing(join(grove.poolDir, "grove-state.json"), false); + + let postCreateRuns = 0; + const repaired = await repairLease( + grove.poolDir, + { repoRoot: repoDir }, + { leaseId: lease.leaseId, action: "resume-acquire" }, + { postCreate: async () => void postCreateRuns++ }, + ); + + expect(repaired).toMatchObject({ + state: "leased", + branch: "preparing-resume-branch", + pendingAcquire: undefined, + }); + expect(postCreateRuns).toBe(0); + }); + + it("repair resume-acquire recreates a missing worktree and replays postCreate", async () => { + const { repoDir, tmpDir, groveDir } = await setupRepo(); + cleanup.tmpDirs.push(tmpDir); + + const grove = await createTestGrove({ repoRoot: repoDir, groveRoot: groveDir }); + const lease = await grove.acquire({ + leaseId: "preparing-post-create", + mode: "branch", + branch: "preparing-post-create-branch", + createBranch: { from: "main", ifExists: "fail" }, + }); + await execa("git", ["worktree", "remove", "--force", lease.path], { cwd: repoDir }); + await markLeasePreparing(join(grove.poolDir, "grove-state.json"), false); + + let postCreateRuns = 0; + const repaired = await repairLease( + grove.poolDir, + { repoRoot: repoDir }, + { leaseId: lease.leaseId, action: "resume-acquire" }, + { postCreate: async () => void postCreateRuns++ }, + ); + + expect(repaired).toMatchObject({ + state: "leased", + branch: "preparing-post-create-branch", + pendingAcquire: undefined, + }); + expect(postCreateRuns).toBe(1); + expect(existsSync(lease.path)).toBe(true); + }); + it("repair resume-acquire completes a quarantined pending acquire", async () => { const { repoDir, tmpDir, groveDir } = await setupRepo(); cleanup.tmpDirs.push(tmpDir); diff --git a/packages/grove/test/state-v1.test.ts b/packages/grove/test/state-v1.test.ts index ded8b8e..64d1dd9 100644 --- a/packages/grove/test/state-v1.test.ts +++ b/packages/grove/test/state-v1.test.ts @@ -74,6 +74,45 @@ describe("Lease-first state", () => { expect(parsed.slots[0]?.state).toBe("leased"); }); + it.each([ + { name: "pre-change intent", postCreatePending: undefined }, + { name: "intent with pending postCreate", postCreatePending: true }, + ])("parses $name", ({ postCreatePending }) => { + const parsed = parseLeaseFirstState({ + slots: [ + { + slotName: "slot-1", + path: "/pool/slot-1", + state: "leased", + createdAt: NOW, + updatedAt: NOW, + }, + ], + leases: [ + { + leaseId: "job-1", + slotName: "slot-1", + path: "/pool/slot-1", + repoRoot: "/repo", + state: "preparing", + pendingAcquire: { + target: { + mode: "detached", + requestedRef: "main", + resolvedRefSha: "abc123", + }, + startedAt: NOW, + ...(postCreatePending === undefined ? {} : { postCreatePending }), + }, + createdAt: NOW, + updatedAt: NOW, + }, + ], + }); + + expect(parsed.leases[0]?.pendingAcquire?.postCreatePending).toBe(postCreatePending); + }); + it("migrates legacy worktrees state", () => { const legacy: GroveState = { worktrees: [ diff --git a/packages/grove/test/transitions.test.ts b/packages/grove/test/transitions.test.ts index cfe15d3..ff605f8 100644 --- a/packages/grove/test/transitions.test.ts +++ b/packages/grove/test/transitions.test.ts @@ -206,8 +206,46 @@ describe("transitionLease", () => { state: "quarantined", pendingAcquire: { target: TARGET, startedAt: NOW }, }); - const next = transitionLease(lease, { type: "REPAIR_RESUME_ACQUIRE" }, NOW); + const next = transitionLease( + lease, + { type: "REPAIR_RESUME_ACQUIRE", postCreatePending: false }, + NOW, + ); expect(next?.state).toBe("preparing"); + expect(next?.pendingAcquire?.postCreatePending).toBe(false); + }); + + it("preparing -> preparing on REPAIR_RESUME_ACQUIRE", () => { + const lease = createPreparingLease({ + leaseId: "job-1", + slotName: "slot-1", + path: "/pool/slot-1", + repoRoot: "/repo", + pendingAcquire: { target: TARGET, startedAt: NOW }, + now: NOW, + }); + + const next = transitionLease( + lease, + { type: "REPAIR_RESUME_ACQUIRE", postCreatePending: true }, + NOW, + ); + expect(next?.state).toBe("preparing"); + expect(next?.pendingAcquire?.postCreatePending).toBe(true); + }); + + it("records completed postCreate work", () => { + const lease = createPreparingLease({ + leaseId: "job-1", + slotName: "slot-1", + path: "/pool/slot-1", + repoRoot: "/repo", + pendingAcquire: { target: TARGET, startedAt: NOW, postCreatePending: true }, + now: NOW, + }); + + const next = transitionLease(lease, { type: "ACQUIRE_POST_CREATE_COMPLETE" }, NOW); + expect(next?.pendingAcquire?.postCreatePending).toBe(false); }); it("quarantined -> releasing on REPAIR_RESUME_CLEANUP when pendingCleanup exists", () => { @@ -291,6 +329,23 @@ describe("transitionLease", () => { }), event: { type: "DESTROY_START" }, }, + { + name: "ACQUIRE_POST_CREATE_COMPLETE from leased", + lease: leasedLease(), + event: { type: "ACQUIRE_POST_CREATE_COMPLETE" }, + }, + { + name: "ACQUIRE_POST_CREATE_COMPLETE without pending work", + lease: createPreparingLease({ + leaseId: "job-1", + slotName: "slot-1", + path: "/pool/slot-1", + repoRoot: "/repo", + pendingAcquire: { target: TARGET, startedAt: NOW, postCreatePending: false }, + now: NOW, + }), + event: { type: "ACQUIRE_POST_CREATE_COMPLETE" }, + }, { name: "RELEASE_PRESERVE_COMPLETE from leased", lease: leasedLease(),