Files
windmill/ephemeral-backends/worktree-pool.ts
Diego Imbert 4f2f7356c0 Fullstack CI Preview (#7665)
* update cf worker hostname

* set remote_url cookie from param

* ephemeral backends v1

* nit

* Run queue server

* ntis

* timeout

* better db process management

* commit hash and worktree

* nit use map

* nit

* err handling

* Revert "err handling"

This reverts commit 19de00c0c0.

* nits

* auto cleanup

* Ephemeral backend command action

* remove checkout

* checkout ee repo

* nits

* process.env.GIT_EE_DEPLOY_KEY_FILE

* resumeURLs logic

* nit

* use windmill flow for ephemeral backend action

* fixes

* new token

* worktree pools

* Delete GH secret on cleanup

* linux deploy

* nit

* nit

* unhandled promises

* nit

* fix docker bridge IP on linux

* pass cf_frontend_url to wmill flow

* git fetch

* release worktree when binary started

* send error

* logger

* logging

* logging 2

* delete log files periodically

* redirect to raw app with logs

* CORS

* MANAGER_AUTH_TOKEN

* Check organization membership

* nit

* bwrap

* nit

* return timeoutAt in resumeUrl

* nit

* Change password

* nit remove https
2026-02-05 17:26:51 +00:00

232 lines
6.8 KiB
TypeScript

import { promisify } from "util";
import { exec } from "child_process";
import path from "path";
const execAsync = promisify(exec);
export interface WorktreeInfo {
id: number;
path: string;
inUse: boolean;
currentCommit?: string;
}
export class WorktreePool {
private worktrees: Map<number, WorktreeInfo> = new Map();
private baseWorktreePath: string;
private nextId: number = 0;
constructor(baseWorktreePath: string = "../windmill-ephemeral-backends") {
this.baseWorktreePath = path.resolve(baseWorktreePath);
}
/**
* Initialize the pool by discovering existing worktrees
*/
async initialize(): Promise<void> {
console.log("🔍 Initializing worktree pool...");
// Ensure base directory exists
try {
await execAsync(`mkdir -p ${this.baseWorktreePath}`);
} catch (error) {
// Directory might already exist
}
// Discover existing worktrees
await this.discoverExistingWorktrees();
console.log(
`✓ Worktree pool initialized with ${this.worktrees.size} existing worktree(s)`
);
}
/**
* Discover existing worktrees from git worktree list
*/
private async discoverExistingWorktrees(): Promise<void> {
try {
const { stdout } = await execAsync("git worktree list --porcelain");
const lines = stdout.split("\n");
let currentWorktreePath: string | null = null;
let isMainWorktree = false;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentWorktreePath = line.substring("worktree ".length);
isMainWorktree = false;
} else if (line.startsWith("branch ")) {
// Main worktree has a branch entry, so this is NOT a detached HEAD worktree
// We want to skip the main worktree
isMainWorktree = true;
} else if (line === "" && currentWorktreePath) {
// End of worktree entry
// Check if this worktree is in our base path and not the main worktree
if (
currentWorktreePath.startsWith(this.baseWorktreePath) &&
!isMainWorktree
) {
// Extract the ID from the path (e.g., .../worktree-0 -> 0)
const match = currentWorktreePath.match(/worktree-(\d+)$/);
if (match) {
const id = parseInt(match[1], 10);
this.worktrees.set(id, {
id,
path: currentWorktreePath,
inUse: false,
currentCommit: undefined,
});
if (id >= this.nextId) {
this.nextId = id + 1;
}
console.log(` Found existing worktree: ${currentWorktreePath}`);
}
}
currentWorktreePath = null;
isMainWorktree = false;
}
}
} catch (error) {
// If git worktree list fails, we'll start with an empty pool
console.log(" No existing worktrees found");
}
}
/**
* Acquire a worktree for the given commit hash
* Returns a free worktree or creates a new one if needed
*/
async acquire(commitHash: string): Promise<WorktreeInfo> {
console.log(`\n📂 Acquiring worktree for commit ${commitHash}...`);
// Try to find a free worktree
for (const worktree of this.worktrees.values()) {
if (!worktree.inUse) {
console.log(` Using existing worktree: ${worktree.path}`);
await this.prepareWorktree(worktree, commitHash);
worktree.inUse = true;
worktree.currentCommit = commitHash;
return worktree;
}
}
// No free worktrees, create a new one
console.log(" No free worktrees available, creating new one...");
const newWorktree = await this.createWorktree(commitHash);
newWorktree.inUse = true;
newWorktree.currentCommit = commitHash;
this.worktrees.set(newWorktree.id, newWorktree);
return newWorktree;
}
/**
* Prepare a worktree for use by discarding changes and checking out the commit
*/
private async prepareWorktree(
worktree: WorktreeInfo,
commitHash: string
): Promise<void> {
console.log(` Preparing worktree at ${worktree.path}...`);
try {
// Discard all local changes
await execAsync("git reset --hard", { cwd: worktree.path });
await execAsync("git clean -fd", { cwd: worktree.path });
// Fetch to ensure we have the latest commits
await execAsync("git fetch origin", { cwd: worktree.path });
// Checkout the target commit
await execAsync(`git checkout ${commitHash}`, { cwd: worktree.path });
console.log(` ✓ Worktree prepared and checked out to ${commitHash}`);
} catch (error) {
throw new Error(
`Failed to prepare worktree at ${worktree.path}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Create a new worktree for the given commit hash
*/
private async createWorktree(commitHash: string): Promise<WorktreeInfo> {
const id = this.nextId++;
const worktreePath = path.join(this.baseWorktreePath, `worktree-${id}`);
console.log(` Creating new worktree at ${worktreePath}...`);
try {
// First, fetch to ensure we have the commit
await execAsync(`git fetch origin ${commitHash}`);
// Create the worktree at the specific commit
// Note: This command must be run from the main repository directory
await execAsync(`git worktree add ${worktreePath} ${commitHash}`);
console.log(` ✓ Worktree created at ${worktreePath}`);
return {
id,
path: worktreePath,
inUse: false,
currentCommit: undefined,
};
} catch (error) {
throw new Error(
`Failed to create worktree at ${worktreePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Release a worktree back to the pool
* The worktree is marked as available but NOT deleted
*/
async release(worktreeId: number): Promise<void> {
const worktree = this.worktrees.get(worktreeId);
if (!worktree) {
console.warn(`⚠️ Worktree ${worktreeId} not found in pool`);
return;
}
console.log(`\n📂 Releasing worktree ${worktreeId} back to pool...`);
// Mark as available
worktree.inUse = false;
worktree.currentCommit = undefined;
console.log(`✓ Worktree ${worktreeId} is now available for reuse`);
}
/**
* Get the EE worktree path for a given worktree
*/
getEEWorktreePath(worktree: WorktreeInfo): string {
return `${worktree.path}_private`;
}
/**
* Get pool statistics
*/
getStats(): {
total: number;
inUse: number;
available: number;
} {
const total = this.worktrees.size;
const inUse = Array.from(this.worktrees.values()).filter(
(w) => w.inUse
).length;
return {
total,
inUse,
available: total - inUse,
};
}
}