import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve, isAbsolute } from "node:path";
import type { TreehouseConfig, NormalizedConfig } from "../types.js";
import { getRepoRoot } from "./git.js";
export const CONFIG_FILENAME = "treehouse.json";
export const CURSOR_CONFIG_PATH = ".cursor/worktrees.json";
const DEFAULT_CONFIG: TreehouseConfig = {
dir: "../worktrees",
defaultTargetBranch: "current",
lockExpiryMinutes: 60,
"setup-worktree": [],
};
function getOS(): "unix" | "windows" {
return process.platform === "win32" ? "windows" : "unix";
}
export async function findConfigPath(): Promise<string | null> {
const repoRoot = await getRepoRoot();
const cwd = process.cwd();
// Check if we're in a worktree (git-common-dir != git-dir)
let worktreePath: string | null = null;
try {
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(exec);
const { stdout: commonDir } = await execAsync("git rev-parse --git-common-dir");
const { stdout: gitDir } = await execAsync("git rev-parse --git-dir");
if (commonDir.trim() !== gitDir.trim()) {
worktreePath = cwd;
}
} catch {
// Not in a worktree
}
// Search order (Cursor-compatible):
const searchPaths: string[] = [];
if (worktreePath) {
searchPaths.push(
join(worktreePath, CURSOR_CONFIG_PATH),
join(worktreePath, CONFIG_FILENAME)
);
}
searchPaths.push(
join(repoRoot, CURSOR_CONFIG_PATH),
join(repoRoot, CONFIG_FILENAME)
);
for (const path of searchPaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
async function resolveSetupCommands(
config: TreehouseConfig,
configDir: string
): Promise<string[]> {
const os = getOS();
let setupValue: string | string[] | undefined;
if (os === "unix" && config["setup-worktree-unix"]) {
setupValue = config["setup-worktree-unix"];
} else if (os === "windows" && config["setup-worktree-windows"]) {
setupValue = config["setup-worktree-windows"];
} else if (config["setup-worktree"]) {
setupValue = config["setup-worktree"];
}
if (!setupValue) {
return [];
}
// Handle script file vs inline commands
if (typeof setupValue === "string") {
const scriptPath = isAbsolute(setupValue)
? setupValue
: resolve(configDir, setupValue);
if (!existsSync(scriptPath)) {
throw new Error(`Setup script not found: ${scriptPath}`);
}
return [scriptPath];
}
return setupValue;
}
export async function loadConfig(): Promise<NormalizedConfig> {
const configPath = await findConfigPath();
if (!configPath) {
throw new Error(
`Config file not found. Run 'treehouse init' first.`
);
}
const configDir = join(configPath, "..");
const content = await readFile(configPath, "utf-8");
const config: TreehouseConfig = JSON.parse(content);
const setupCommands = await resolveSetupCommands(config, configDir);
return {
dir: config.dir ?? DEFAULT_CONFIG.dir,
defaultTargetBranch: config.defaultTargetBranch ?? DEFAULT_CONFIG.defaultTargetBranch,
lockExpiryMinutes: config.lockExpiryMinutes ?? DEFAULT_CONFIG.lockExpiryMinutes,
setupCommands,
cleanup: {
enabled: config["worktree.cleanup.enabled"] ?? false,
intervalHours: config["worktree.cleanup.intervalHours"] ?? 6,
retentionDays: config["worktree.cleanup.retentionDays"] ?? 7,
maxSizeGB: config["worktree.cleanup.maxSizeGB"] ?? 0,
},
};
}
export async function configExists(): Promise<boolean> {
const configPath = await findConfigPath();
return configPath !== null;
}
export async function createConfig(repoRoot: string): Promise<string> {
const configPath = join(repoRoot, CONFIG_FILENAME);
await writeFile(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
return configPath;
}
export async function getWorktreeDir(config: NormalizedConfig): Promise<string> {
const repoRoot = await getRepoRoot();
if (isAbsolute(config.dir)) {
return config.dir;
}
return resolve(repoRoot, config.dir);
}
export { DEFAULT_CONFIG };