import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join, isAbsolute } from "node:path";
import getFolderSize from "get-folder-size";
import type {
CreateOptions,
CompleteOptions,
LockOptions,
ConflictResolution,
CleanOptions,
NormalizedConfig,
WorktreeMetadata,
LockInfo,
} from "./types.js";
import { BRANCH_CURRENT, DEFAULT_AGENT_ID } from "./types.js";
import {
isGitAvailable,
isGitRepository,
getRepoRoot,
getCurrentBranch,
branchExists,
hasUncommittedChanges,
hasMergeConflicts,
getConflictedFiles,
getWorktreeList,
createWorktree as gitCreateWorktree,
removeWorktree as gitRemoveWorktree,
lockWorktree as gitLockWorktree,
unlockWorktree as gitUnlockWorktree,
pruneWorktrees,
merge,
abortMerge,
checkout,
pull,
push,
commit,
deleteBranch,
deleteRemoteBranch,
resolveConflict,
commitMerge,
getStatus,
getLog,
execCommand,
} from "./utils/git.js";
import {
loadConfig,
createConfig,
getWorktreeDir,
CONFIG_FILENAME,
CURSOR_CONFIG_PATH,
DEFAULT_CONFIG,
} from "./utils/config.js";
import {
trackWorktree,
removeWorktreeMetadata,
setWorktreeLock,
isWorktreeLocked,
getLockInfo,
getWorktreeMetadata,
getAllWorktreesMetadata,
} from "./utils/metadata.js";
import {
validateWorktreeName,
validateBranchName,
validateAgentId,
validateMessage,
validateLockExpiry,
} from "./utils/validation.js";
export interface TreehouseResult {
success: boolean;
message: string;
data?: unknown;
}
export class Treehouse {
private config: NormalizedConfig | null = null;
async ensureGitAvailable(): Promise<void> {
if (!(await isGitAvailable())) {
throw new Error("Git is not available. Please install git and add it to PATH.");
}
}
async ensureGitVersionSupported(): Promise<void> {
const { getGitVersion, isGitVersionSupported } = await import("./utils/git.js");
const version = await getGitVersion();
if (!version) {
throw new Error("Could not determine git version.");
}
if (!isGitVersionSupported(version)) {
throw new Error(
`Git version ${version.major}.${version.minor}.${version.patch} is not supported. ` +
`Treehouse requires git 2.5 or higher for worktree support.`
);
}
}
async ensureInRepository(): Promise<void> {
if (!(await isGitRepository())) {
throw new Error("Not in a git repository.");
}
}
async getConfig(): Promise<NormalizedConfig> {
if (!this.config) {
this.config = await loadConfig();
}
return this.config;
}
async init(): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureGitVersionSupported();
await this.ensureInRepository();
const repoRoot = await getRepoRoot();
if (existsSync(join(repoRoot, CONFIG_FILENAME))) {
return {
success: false,
message: `Config file already exists at ${CONFIG_FILENAME}`,
};
}
if (existsSync(join(repoRoot, CURSOR_CONFIG_PATH))) {
return {
success: true,
message: `Cursor config found at ${CURSOR_CONFIG_PATH}. Using existing configuration.`,
};
}
const configPath = await createConfig(repoRoot);
return {
success: true,
message: `Created ${configPath}`,
data: DEFAULT_CONFIG,
};
}
async list(): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const worktrees = await getWorktreeList();
const metadata = await getAllWorktreesMetadata();
// Enrich worktree info with metadata
const enrichedWorktrees = worktrees.map((wt) => {
const meta = metadata.find((m) => m.path === wt.path);
return {
...wt,
name: meta?.name,
createdAt: meta?.createdAt,
lastAccessed: meta?.lastAccessed,
lock: meta?.lock,
};
});
return {
success: true,
message: "Worktrees retrieved",
data: enrichedWorktrees,
};
}
async create(options: CreateOptions): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const { name, branch, agentId, lockMessage } = options;
if (!name) {
throw new Error("Worktree name is required.");
}
// Validate inputs
const nameValidation = validateWorktreeName(name);
if (!nameValidation.valid) {
throw new Error(`Invalid worktree name: ${nameValidation.error}`);
}
if (branch) {
const branchValidation = validateBranchName(branch);
if (!branchValidation.valid) {
throw new Error(`Invalid branch name: ${branchValidation.error}`);
}
}
if (agentId) {
const agentValidation = validateAgentId(agentId);
if (!agentValidation.valid) {
throw new Error(`Invalid agent ID: ${agentValidation.error}`);
}
}
if (lockMessage) {
const messageValidation = validateMessage(lockMessage);
if (!messageValidation.valid) {
throw new Error(`Invalid lock message: ${messageValidation.error}`);
}
}
const config = await this.getConfig();
const repoRoot = await getRepoRoot();
const worktreeDir = await getWorktreeDir(config);
const worktreePath = join(worktreeDir, name);
if (existsSync(worktreePath)) {
throw new Error(`Worktree already exists at ${worktreePath}`);
}
// Create worktree directory if needed
if (!existsSync(worktreeDir)) {
await mkdir(worktreeDir, { recursive: true });
}
// Determine branch name
const branchName = branch || name;
const branchAlreadyExists = await branchExists(branchName);
// Create the worktree
await gitCreateWorktree(worktreePath, branchName, !branchAlreadyExists);
// Run setup commands
const setupErrors: string[] = [];
for (const cmd of config.setupCommands) {
// Skip comments
if (cmd.trim().startsWith("#")) continue;
// Check if it's a script file
if (isAbsolute(cmd) && existsSync(cmd)) {
try {
await execCommand(cmd, worktreePath, { ROOT_WORKTREE_PATH: repoRoot });
} catch (e: unknown) {
const error = e as Error;
setupErrors.push(`Script ${cmd}: ${error.message}`);
}
continue;
}
// Execute command with ROOT_WORKTREE_PATH as environment variable
try {
await execCommand(cmd, worktreePath, { ROOT_WORKTREE_PATH: repoRoot });
} catch (e: unknown) {
const error = e as Error;
setupErrors.push(`${cmd}: ${error.message}`);
}
}
// Create lock if agent ID provided
let lock: LockInfo | undefined;
if (agentId) {
const now = new Date();
const expiresAt = new Date(now.getTime() + config.lockExpiryMinutes * 60 * 1000);
lock = {
agentId,
lockedAt: now.toISOString(),
message: lockMessage,
expiresAt: expiresAt.toISOString(),
};
}
// Track in metadata
await trackWorktree(name, branchName, worktreePath, lock);
const result: TreehouseResult = {
success: setupErrors.length === 0,
message: setupErrors.length > 0
? `Worktree created but setup had errors: ${setupErrors.join("; ")}`
: `Worktree '${name}' created at ${worktreePath}`,
data: {
name,
branch: branchName,
path: worktreePath,
lock,
},
};
return result;
}
async status(name?: string): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
if (name) {
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found in metadata.`);
}
if (!existsSync(meta.path)) {
throw new Error(`Worktree path does not exist: ${meta.path}`);
}
const gitStatus = await getStatus(meta.path);
const recentLog = await getLog(5, meta.path);
const hasChanges = await hasUncommittedChanges(meta.path);
const currentBranch = await getCurrentBranch(meta.path);
return {
success: true,
message: `Status for worktree '${name}'`,
data: {
...meta,
currentBranch,
hasUncommittedChanges: hasChanges,
gitStatus,
recentCommits: recentLog,
},
};
}
// Return status for all worktrees
return this.list();
}
async complete(options: CompleteOptions): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const { name, merge: shouldMerge, squash, targetBranch, deleteBranch: shouldDeleteBranch, message } = options;
if (!name) {
throw new Error("Worktree name is required.");
}
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
if (!existsSync(meta.path)) {
throw new Error(`Worktree path does not exist: ${meta.path}`);
}
// Check for uncommitted changes
if (await hasUncommittedChanges(meta.path)) {
throw new Error("Worktree has uncommitted changes. Please commit or stash them first.");
}
const repoRoot = await getRepoRoot();
if (shouldMerge) {
// Determine target branch
let target = targetBranch;
if (!target) {
// If no target branch specified, use the config default
const config = await this.getConfig();
target = config.defaultTargetBranch;
}
// If target is "current", resolve it to the actual current branch
if (target === BRANCH_CURRENT) {
target = await getCurrentBranch(repoRoot);
}
const worktreeBranch = await getCurrentBranch(meta.path);
// Switch to target branch in main repo
await checkout(target, repoRoot);
// Pull latest
try {
await pull(repoRoot);
} catch {
// Pull might fail if no upstream, continue anyway
}
// Perform merge
try {
await merge(worktreeBranch, squash || false, repoRoot);
// If squash, need to commit
if (squash) {
const commitMsg = message || `Merge worktree ${name} (${worktreeBranch})`;
await commit(commitMsg, repoRoot);
}
// Push
try {
await push(repoRoot);
} catch {
// Push might fail, that's okay
}
// Delete branch if requested
if (shouldDeleteBranch) {
try {
await deleteBranch(worktreeBranch, false, repoRoot);
} catch {
// Branch delete might fail
}
try {
await deleteRemoteBranch(worktreeBranch, repoRoot);
} catch {
// Remote delete might fail
}
}
return {
success: true,
message: `Worktree '${name}' merged into ${target}`,
data: {
mergedBranch: worktreeBranch,
targetBranch: target,
branchDeleted: shouldDeleteBranch,
},
};
} catch (e: unknown) {
// Check for merge conflicts
if (await hasMergeConflicts(repoRoot)) {
const conflicts = await getConflictedFiles(repoRoot);
return {
success: false,
message: "Merge conflicts detected",
data: {
conflicts,
worktreeBranch,
targetBranch: target,
},
};
}
throw e;
}
}
return {
success: true,
message: `Worktree '${name}' is ready to be removed. Run 'treehouse remove ${name}' to remove it.`,
};
}
async remove(name: string, force: boolean = false): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
if (!name) {
throw new Error("Worktree name is required.");
}
const meta = await getWorktreeMetadata(name);
// Try to find worktree even without metadata
let worktreePath: string | undefined = meta?.path;
if (!worktreePath) {
// Try to find by name in worktree list
const worktrees = await getWorktreeList();
const config = await this.getConfig();
const worktreeDir = await getWorktreeDir(config);
const expectedPath = join(worktreeDir, name);
const found = worktrees.find(
(wt) => wt.path === expectedPath || wt.branch === name
);
if (found) {
worktreePath = found.path;
}
}
if (!worktreePath || !existsSync(worktreePath)) {
throw new Error(`Worktree '${name}' not found.`);
}
// Check for uncommitted changes
if (!force && (await hasUncommittedChanges(worktreePath))) {
throw new Error(
"Worktree has uncommitted changes. Use --force to remove anyway."
);
}
// Remove the worktree
await gitRemoveWorktree(worktreePath, force);
// Remove from metadata
await removeWorktreeMetadata(name);
return {
success: true,
message: `Worktree '${name}' removed.`,
};
}
async lock(options: LockOptions): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const { name, agentId, message, expiryMinutes } = options;
if (!name) {
throw new Error("Worktree name is required.");
}
// Validate inputs
if (agentId) {
const agentValidation = validateAgentId(agentId);
if (!agentValidation.valid) {
throw new Error(`Invalid agent ID: ${agentValidation.error}`);
}
}
if (message) {
const messageValidation = validateMessage(message);
if (!messageValidation.valid) {
throw new Error(`Invalid message: ${messageValidation.error}`);
}
}
if (expiryMinutes !== undefined) {
const expiryValidation = validateLockExpiry(expiryMinutes);
if (!expiryValidation.valid) {
throw new Error(`Invalid expiry time: ${expiryValidation.error}`);
}
}
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
// Check if already locked
if (await isWorktreeLocked(name)) {
const existingLock = await getLockInfo(name);
throw new Error(
`Worktree '${name}' is already locked by ${existingLock?.agentId || "unknown"}.`
);
}
const config = await this.getConfig();
const now = new Date();
const expiry = expiryMinutes ?? config.lockExpiryMinutes;
const expiresAt = new Date(now.getTime() + expiry * 60 * 1000);
const lock: LockInfo = {
agentId: agentId || DEFAULT_AGENT_ID,
lockedAt: now.toISOString(),
message,
expiresAt: expiresAt.toISOString(),
};
await setWorktreeLock(name, lock);
// Also use git's native lock
try {
await gitLockWorktree(meta.path, message);
} catch {
// Git lock might fail if already locked, that's okay
}
return {
success: true,
message: `Worktree '${name}' locked.`,
data: lock,
};
}
async unlock(name: string): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
if (!name) {
throw new Error("Worktree name is required.");
}
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
await setWorktreeLock(name, undefined);
// Also unlock git's native lock
try {
await gitUnlockWorktree(meta.path);
} catch {
// Unlock might fail if not locked, that's okay
}
return {
success: true,
message: `Worktree '${name}' unlocked.`,
};
}
async conflicts(name?: string): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
let cwd: string | undefined;
if (name) {
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
cwd = meta.path;
}
const hasConflicts = await hasMergeConflicts(cwd);
if (!hasConflicts) {
return {
success: true,
message: "No merge conflicts detected.",
data: { conflicts: [] },
};
}
const conflictedFiles = await getConflictedFiles(cwd);
return {
success: true,
message: "Merge conflicts detected.",
data: { conflicts: conflictedFiles },
};
}
async resolveConflicts(
resolution: ConflictResolution
): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const { strategy, name } = resolution;
let cwd: string | undefined;
if (name) {
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
cwd = meta.path;
}
const conflictedFiles = await getConflictedFiles(cwd);
if (conflictedFiles.length === 0) {
return {
success: true,
message: "No conflicts to resolve.",
};
}
for (const file of conflictedFiles) {
await resolveConflict(file, strategy, cwd);
}
// Check if all resolved
const stillConflicted = await hasMergeConflicts(cwd);
if (!stillConflicted) {
await commitMerge(cwd);
return {
success: true,
message: `All conflicts resolved using --${strategy} strategy. Merge committed.`,
};
}
return {
success: false,
message: "Some conflicts may remain. Please review.",
};
}
async abort(name?: string): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
let cwd: string | undefined;
if (name) {
const meta = await getWorktreeMetadata(name);
if (!meta) {
throw new Error(`Worktree '${name}' not found.`);
}
cwd = meta.path;
}
try {
await abortMerge(cwd);
return {
success: true,
message: "Merge aborted. Repository state restored.",
};
} catch {
return {
success: false,
message: "Failed to abort merge. No merge in progress?",
};
}
}
async prune(): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const output = await pruneWorktrees();
return {
success: true,
message: "Prune completed.",
data: { output },
};
}
async clean(options: CleanOptions = {}): Promise<TreehouseResult> {
await this.ensureGitAvailable();
await this.ensureInRepository();
const { dryRun } = options;
const config = await this.getConfig();
if (!config.cleanup.enabled && !dryRun) {
return {
success: false,
message:
"Cleanup is disabled in config. Enable it or use --dry-run to preview.",
};
}
const allMetadata = await getAllWorktreesMetadata();
const now = new Date();
const toRemove: WorktreeMetadata[] = [];
// Check retention days
if (config.cleanup.retentionDays > 0) {
for (const wt of allMetadata) {
const lastAccessed = new Date(wt.lastAccessed);
const daysSinceAccess =
(now.getTime() - lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceAccess > config.cleanup.retentionDays) {
if (existsSync(wt.path)) {
const hasChanges = await hasUncommittedChanges(wt.path);
if (!hasChanges) {
toRemove.push(wt);
}
}
}
}
}
// Check max size using cross-platform folder size calculation
if (config.cleanup.maxSizeGB > 0) {
// Get sizes and sort by last accessed
const withSize: Array<WorktreeMetadata & { sizeGB: number }> = [];
for (const wt of allMetadata) {
if (existsSync(wt.path) && !toRemove.find((r) => r.name === wt.name)) {
try {
const sizeBytes = await getFolderSize.loose(wt.path);
const sizeGB = sizeBytes / 1024 / 1024 / 1024;
withSize.push({ ...wt, sizeGB });
} catch {
// Skip if can't get size
}
}
}
const totalSize = withSize.reduce((sum, wt) => sum + wt.sizeGB, 0);
if (totalSize > config.cleanup.maxSizeGB) {
// Sort by oldest first
withSize.sort(
(a, b) =>
new Date(a.lastAccessed).getTime() -
new Date(b.lastAccessed).getTime()
);
let currentSize = totalSize;
for (const wt of withSize) {
if (currentSize <= config.cleanup.maxSizeGB) break;
if (!toRemove.find((r) => r.name === wt.name)) {
toRemove.push(wt);
currentSize -= wt.sizeGB;
}
}
}
}
if (toRemove.length === 0) {
return {
success: true,
message: "No worktrees to clean.",
data: { removed: [] },
};
}
if (dryRun) {
return {
success: true,
message: `Would remove ${toRemove.length} worktree(s).`,
data: {
wouldRemove: toRemove.map((wt) => ({
name: wt.name,
lastAccessed: wt.lastAccessed,
})),
},
};
}
const removed: string[] = [];
const errors: string[] = [];
for (const wt of toRemove) {
try {
if (existsSync(wt.path)) {
await gitRemoveWorktree(wt.path, true);
}
await removeWorktreeMetadata(wt.name);
removed.push(wt.name);
} catch (e: unknown) {
const error = e as Error;
errors.push(`${wt.name}: ${error.message}`);
}
}
return {
success: errors.length === 0,
message:
errors.length > 0
? `Cleaned ${removed.length} worktree(s) with ${errors.length} error(s).`
: `Cleaned ${removed.length} worktree(s).`,
data: { removed, errors },
};
}
}
export const treehouse = new Treehouse();