worktree-tool
Version:
A command-line tool for managing Git worktrees with integrated tmux/shell session management
245 lines • 8.82 kB
JavaScript
import { promises as fs } from "fs";
import * as path from "path";
import { getErrorMessage } from "../utils/error-handler.js";
import { ConfigError, FileSystemError } from "../utils/errors.js";
import { findProjectRoot } from "../utils/find-root.js";
import { validateCommand } from "../utils/validation.js";
const CONFIG_FILENAME = ".worktree-config.json";
const CONFIG_VERSION = "1.0.0";
/**
* Load the worktree configuration from the current directory
* @returns The configuration or null if not found
*/
export async function loadConfig() {
try {
const projectRoot = await findProjectRoot();
if (!projectRoot) {
return null;
}
const configPath = path.join(projectRoot, CONFIG_FILENAME);
const content = await fs.readFile(configPath, "utf-8");
const data = JSON.parse(content);
if (!validateConfig(data)) {
throw new ConfigError("Invalid configuration format");
}
// Validate commands if present
if (data.commands) {
for (const [name, command] of Object.entries(data.commands)) {
try {
// Extract the actual command string for validation
const commandStr = typeof command === "string" ? command : command.command;
validateCommand(name, commandStr);
}
catch (error) {
throw new ConfigError(error instanceof Error ? error.message : "Invalid command");
}
}
}
return data;
}
catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
// Config file doesn't exist, which is okay
return null;
}
if (error instanceof ConfigError) {
throw error;
}
if (error instanceof SyntaxError) {
throw new ConfigError("Invalid JSON in configuration file", error);
}
throw new FileSystemError(`Failed to read configuration: ${getErrorMessage(error)}`);
}
}
/**
* Save the worktree configuration to the current directory
* @param config The configuration to save
*/
export async function saveConfig(config) {
try {
// During init, there's no existing config, so save in current directory
// Otherwise, find the project root
const projectRoot = await findProjectRoot() ?? process.cwd();
const configPath = path.join(projectRoot, CONFIG_FILENAME);
const content = JSON.stringify(config, null, 2);
await fs.writeFile(configPath, content, "utf-8");
}
catch (error) {
if (error instanceof ConfigError) {
throw error;
}
throw new FileSystemError(`Failed to save configuration: ${getErrorMessage(error)}`);
}
}
/**
* Get the default configuration for a project
* @param projectName The name of the project
* @returns Default configuration
*/
export function getDefaultConfig(projectName) {
return {
version: CONFIG_VERSION,
projectName,
mainBranch: "main",
baseDir: ".worktrees",
tmux: true,
commands: {},
};
}
/**
* Validate port range format
* @param range The port range string to validate
* @returns True if valid, false otherwise
*/
function validatePortRange(range) {
const regex = /^(\d+)-(\d+)$/;
const match = regex.exec(range);
if (!match?.[1] || !match[2]) {
return false;
}
const start = parseInt(match[1]);
const end = parseInt(match[2]);
return start < end && start >= 1024 && end <= 65535;
}
/**
* Validate that an unknown object is a valid WorktreeConfig
* @param config The object to validate
* @returns True if valid, false otherwise
*/
export function validateConfig(config) {
if (!config || typeof config !== "object") {
return false;
}
const obj = config;
// Check required fields
if (typeof obj.version !== "string") {
return false;
}
if (typeof obj.projectName !== "string" || obj.projectName.trim() === "") {
return false;
}
if (typeof obj.mainBranch !== "string" || obj.mainBranch.trim() === "") {
return false;
}
if (typeof obj.baseDir !== "string" || obj.baseDir.trim() === "") {
return false;
}
if (typeof obj.tmux !== "boolean") {
return false;
}
// Validate optional autoSort field
if (obj.autoSort !== undefined && typeof obj.autoSort !== "boolean") {
return false;
}
// Validate optional availablePorts field
if (obj.availablePorts !== undefined) {
if (typeof obj.availablePorts !== "string" || !validatePortRange(obj.availablePorts)) {
return false;
}
}
// Validate optional autoRemove field
if (obj.autoRemove !== undefined && typeof obj.autoRemove !== "boolean") {
return false;
}
// Validate optional commands field
if (obj.commands !== undefined) {
if (typeof obj.commands !== "object" || obj.commands === null || Array.isArray(obj.commands)) {
return false;
}
// Check that all values are valid CommandConfig (string or object)
for (const value of Object.values(obj.commands)) {
if (typeof value === "string") {
// String format is valid
continue;
}
else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
// Object format - validate structure
const cmdObj = value;
// Command property is required and must be string
if (typeof cmdObj.command !== "string") {
return false;
}
// Mode property is optional but must be valid if present
if (cmdObj.mode !== undefined) {
const validModes = ["window", "inline", "background", "exit"];
if (!validModes.includes(cmdObj.mode)) {
return false;
}
}
// autoRun property is optional but must be boolean if present
if (cmdObj.autoRun !== undefined && typeof cmdObj.autoRun !== "boolean") {
return false;
}
// numPorts property is optional but must be non-negative number if present
if (cmdObj.numPorts !== undefined && (typeof cmdObj.numPorts !== "number" || cmdObj.numPorts < 0)) {
return false;
}
}
else {
// Invalid format
return false;
}
}
}
// Validate optional autoRemove field
if (obj.autoRemove !== undefined && typeof obj.autoRemove !== "boolean") {
return false;
}
return true;
}
/**
* Check if a configuration already exists in the current directory
*/
export async function configExists() {
try {
const projectRoot = await findProjectRoot();
if (!projectRoot) {
return false;
}
const configPath = path.join(projectRoot, CONFIG_FILENAME);
await fs.access(configPath);
return true;
}
catch {
return false;
}
}
/**
* Update the .gitignore file to ignore the worktrees directory
* @param baseDir The base directory for worktrees
*/
export async function updateGitignore(baseDir) {
try {
const projectRoot = await findProjectRoot() ?? process.cwd();
const gitignorePath = path.join(projectRoot, ".gitignore");
let content = "";
try {
content = await fs.readFile(gitignorePath, "utf-8");
}
catch (error) {
if (error && typeof error === "object" && "code" in error && error.code !== "ENOENT") {
throw error;
}
// File doesn't exist, we'll create it
}
// Check if the baseDir is already in .gitignore
const lines = content.split("\n");
const baseDirPattern = `${baseDir}/`;
if (!lines.some((line) => line.trim() === baseDirPattern || line.trim() === baseDir)) {
// Add baseDir to .gitignore
if (content && !content.endsWith("\n")) {
content += "\n";
}
// Add a comment if this is the first wtt entry
if (!content.includes("wtt")) {
content += "\n# wtt worktrees\n";
}
content += `${baseDirPattern}\n`;
await fs.writeFile(gitignorePath, content, "utf-8");
}
}
catch (error) {
throw new FileSystemError(`Failed to update .gitignore: ${getErrorMessage(error)}`);
}
}
//# sourceMappingURL=config.js.map