Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 5 additions & 9 deletions packages/grove/src/config.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -10,13 +10,9 @@ function expandEnv(str: string): string {
}

export async function resolveGroveDir(repoRoot: string, root?: string): Promise<string> {
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) {
Expand Down
41 changes: 36 additions & 5 deletions packages/grove/src/lease-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ export async function finalizeLeaseCheckout(
return enrichLeaseReadOnly(lease);
}

async function persistPostCreatePending(
poolDir: string,
repoRoot: string,
leaseId: string,
postCreatePending: boolean,
): Promise<void> {
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -238,6 +259,7 @@ export async function acquireLease(
await quarantineFailedAcquire(poolDir, repoRoot, leaseIdForCheckout, reason, "postCreate");
throw err;
}
await persistPostCreatePending(poolDir, repoRoot, leaseIdForCheckout, false);
}

try {
Expand Down Expand Up @@ -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") {
Expand All @@ -304,14 +333,15 @@ 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;
});

const options = targetToAcquireOptions(pendingTarget, leaseId);
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) {
Expand All @@ -333,6 +363,7 @@ export async function resumeAcquireLease(
await quarantineFailedAcquire(poolDir, repoRoot, leaseId, reason, "postCreate");
throw err;
}
await persistPostCreatePending(poolDir, repoRoot, leaseId, false);
}

try {
Expand Down
25 changes: 22 additions & 3 deletions packages/grove/src/lease-destroy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions packages/grove/src/lease-repair.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { GroveConfig } from "./schemas.js";
import type {
DestroyLeaseOptions,
GroveLease,
ReleaseResult,
RepairLeaseOptions,
Expand Down Expand Up @@ -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 };
}
Expand Down
7 changes: 5 additions & 2 deletions packages/grove/src/process/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,11 @@ export async function findInWorktree(worktreePath: string): Promise<ProcessScanR
return { processes: result, unverified: false };
} else if (process.platform === "darwin") {
try {
const { stdout } = await execa("lsof", ["-F", "pn", "-d", "cwd"], { reject: false });
const lines = stdout.split("\n");
const scan = await execa("lsof", ["-F", "pn", "-d", "cwd"], { reject: false });
if (scan.failed) {
return { processes: [], unverified: true };
}
const lines = scan.stdout.split("\n");
let currentPid = -1;
for (const line of lines) {
if (line.startsWith("p")) {
Expand Down
1 change: 1 addition & 0 deletions packages/grove/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export type GroveLeaseTarget = z.infer<typeof GroveLeaseTargetSchema>;
export const PendingAcquireSchema = z.object({
target: GroveLeaseTargetSchema,
startedAt: z.string(),
postCreatePending: z.boolean().optional(),
});

export type PendingAcquire = z.infer<typeof PendingAcquireSchema>;
Expand Down
8 changes: 6 additions & 2 deletions packages/grove/src/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 30 additions & 3 deletions packages/grove/src/transitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 =
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -172,15 +188,26 @@ 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}`,
);
}
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") {
Expand Down
16 changes: 14 additions & 2 deletions packages/grove/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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));
});
});
31 changes: 31 additions & 0 deletions packages/grove/test/lease-destroy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/grove/test/lease-hooks.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});
Expand Down
Loading
Loading