init
This commit is contained in:
979
.opencode/skills/worktree/scripts/worktree.cjs
Executable file
979
.opencode/skills/worktree/scripts/worktree.cjs
Executable file
@@ -0,0 +1,979 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Git Worktree Manager for ClaudeKit
|
||||
* Cross-platform Node.js script for creating isolated git worktrees
|
||||
*
|
||||
* Usage: node worktree.cjs <command> [options]
|
||||
* Commands:
|
||||
* create <project> <feature> Create a new worktree (project optional for standalone)
|
||||
* remove <name-or-path> Remove a worktree and its branch
|
||||
* info Get repo info (type, projects, env files)
|
||||
* list List existing worktrees
|
||||
*
|
||||
* Options:
|
||||
* --prefix <type> Branch prefix (feat|fix|refactor|docs|test|chore|perf)
|
||||
* --worktree-root <path> Explicit worktree directory (Claude's decision)
|
||||
* --json Output in JSON format for LLM consumption
|
||||
* --env <files> Comma-separated list of .env files to copy (legacy)
|
||||
* --dry-run Show what would be done without executing
|
||||
* --no-prefix Skip branch prefix and preserve original case
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function sanitizeBranchPrefix(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) return 'feat';
|
||||
const safe = raw
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 20);
|
||||
return safe || 'feat';
|
||||
}
|
||||
|
||||
function isSafeEnvFileName(fileName) {
|
||||
if (!fileName || typeof fileName !== 'string') return false;
|
||||
if (fileName.includes('\0')) return false;
|
||||
if (path.isAbsolute(fileName)) return false;
|
||||
const normalized = path.normalize(fileName.trim());
|
||||
if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false;
|
||||
if (normalized.includes(path.sep)) return false;
|
||||
return /^\.env[\w.-]*$/.test(normalized);
|
||||
}
|
||||
|
||||
// Minimum Node.js version check
|
||||
const MIN_NODE_VERSION = 18;
|
||||
const nodeVersion = parseInt(process.version.slice(1).split('.')[0], 10);
|
||||
if (nodeVersion < MIN_NODE_VERSION) {
|
||||
outputError('NODE_VERSION_ERROR', `Node.js ${MIN_NODE_VERSION}+ required. Current: ${process.version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
const jsonOutput = args.includes('--json');
|
||||
const jsonIndex = args.indexOf('--json');
|
||||
if (jsonIndex > -1) args.splice(jsonIndex, 1);
|
||||
|
||||
const prefixIndex = args.indexOf('--prefix');
|
||||
let branchPrefix = 'feat';
|
||||
let branchPrefixWarning = null;
|
||||
if (prefixIndex > -1) {
|
||||
const rawPrefix = args[prefixIndex + 1] || 'feat';
|
||||
branchPrefix = sanitizeBranchPrefix(rawPrefix);
|
||||
if (branchPrefix !== rawPrefix.toLowerCase()) {
|
||||
branchPrefixWarning = `Branch prefix sanitized: "${rawPrefix}" → "${branchPrefix}"`;
|
||||
}
|
||||
args.splice(prefixIndex, 2);
|
||||
}
|
||||
|
||||
const envIndex = args.indexOf('--env');
|
||||
let envFilesToCopy = [];
|
||||
if (envIndex > -1) {
|
||||
envFilesToCopy = (args[envIndex + 1] || '').split(',').map(v => v.trim()).filter(Boolean);
|
||||
args.splice(envIndex, 2);
|
||||
}
|
||||
|
||||
const dryRunIndex = args.indexOf('--dry-run');
|
||||
const dryRun = dryRunIndex > -1;
|
||||
if (dryRunIndex > -1) args.splice(dryRunIndex, 1);
|
||||
|
||||
// --no-prefix: skip branch prefix and preserve original case in feature name
|
||||
const noPrefixIndex = args.indexOf('--no-prefix');
|
||||
const noPrefix = noPrefixIndex > -1;
|
||||
if (noPrefixIndex > -1) args.splice(noPrefixIndex, 1);
|
||||
|
||||
// --worktree-root: explicit override for worktree location (Claude's decision)
|
||||
const worktreeRootIndex = args.indexOf('--worktree-root');
|
||||
let explicitWorktreeRoot = null;
|
||||
if (worktreeRootIndex > -1) {
|
||||
explicitWorktreeRoot = args[worktreeRootIndex + 1];
|
||||
args.splice(worktreeRootIndex, 2);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
// For create: args[1] is project (or feature for standalone), args[2] is feature
|
||||
// For remove: args[1] is worktree name or path
|
||||
const arg1 = args[1];
|
||||
const arg2 = args[2];
|
||||
|
||||
// Output helpers
|
||||
function output(data) {
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
if (data.success) {
|
||||
console.log(`\n✅ ${data.message}`);
|
||||
if (data.worktreePath) {
|
||||
console.log(`\n📋 Next Steps:`);
|
||||
console.log(` 1. cd ${data.worktreePath}`);
|
||||
console.log(` 2. claude`);
|
||||
console.log(` 3. Start working on your feature`);
|
||||
console.log(`\n🧹 Cleanup when done:`);
|
||||
console.log(` git worktree remove ${data.worktreePath}`);
|
||||
console.log(` git branch -d ${data.branch}`);
|
||||
}
|
||||
if (data.envTemplatesCopied && data.envTemplatesCopied.length > 0) {
|
||||
console.log(`\n📄 Environment templates copied:`);
|
||||
data.envTemplatesCopied.forEach(t => console.log(` ✓ ${t.from} → ${t.to}`));
|
||||
} else if (data.envFilesCopied && data.envFilesCopied.length > 0) {
|
||||
console.log(`\n📄 Environment files copied:`);
|
||||
data.envFilesCopied.forEach(f => console.log(` ✓ ${f}`));
|
||||
}
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
console.log(`\n⚠️ Warnings:`);
|
||||
data.warnings.forEach(w => console.log(` ${w}`));
|
||||
}
|
||||
} else if (data.info) {
|
||||
// Info output
|
||||
console.log(`\n📦 Repository Info:`);
|
||||
console.log(` Type: ${data.repoType}`);
|
||||
console.log(` Base branch: ${data.baseBranch}`);
|
||||
if (data.worktreeRoot) {
|
||||
console.log(`\n📂 Worktree location:`);
|
||||
console.log(` Path: ${data.worktreeRoot}`);
|
||||
console.log(` Source: ${data.worktreeRootSource}`);
|
||||
}
|
||||
if (data.projects && data.projects.length > 0) {
|
||||
console.log(`\n📁 Available projects:`);
|
||||
data.projects.forEach(p => console.log(` - ${p.name} (${p.path})`));
|
||||
}
|
||||
if (data.envFiles && data.envFiles.length > 0) {
|
||||
console.log(`\n🔐 Environment files found:`);
|
||||
data.envFiles.forEach(f => console.log(` - ${f}`));
|
||||
}
|
||||
if (data.dirtyState) {
|
||||
console.log(`\n⚠️ Working directory has uncommitted changes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outputError(code, message, details = {}) {
|
||||
const errorData = {
|
||||
success: false,
|
||||
error: { code, message, ...details }
|
||||
};
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(errorData, null, 2));
|
||||
} else {
|
||||
console.error(`\n❌ Error [${code}]: ${message}`);
|
||||
if (details.suggestion) {
|
||||
console.error(` 💡 ${details.suggestion}`);
|
||||
}
|
||||
if (details.availableProjects) {
|
||||
console.error(`\n Available projects:`);
|
||||
details.availableProjects.forEach(p => console.error(` - ${p}`));
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Git command wrapper with error handling
|
||||
function git(command, options = {}) {
|
||||
try {
|
||||
const result = execSync(`git ${command}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: options.silent ? 'pipe' : ['pipe', 'pipe', 'pipe'],
|
||||
cwd: options.cwd || process.cwd()
|
||||
});
|
||||
return { success: true, output: result.trim() };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
stderr: error.stderr?.toString().trim() || '',
|
||||
code: error.status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if in git repo
|
||||
function checkGitRepo() {
|
||||
const result = git('rev-parse --show-toplevel', { silent: true });
|
||||
if (!result.success) {
|
||||
outputError('NOT_GIT_REPO', 'Not in a git repository', {
|
||||
suggestion: 'Run this command from within a git repository'
|
||||
});
|
||||
}
|
||||
return result.output;
|
||||
}
|
||||
|
||||
// Check git version supports worktree
|
||||
function checkGitVersion() {
|
||||
const result = git('worktree list', { silent: true });
|
||||
if (!result.success && result.stderr.includes('not a git command')) {
|
||||
outputError('GIT_VERSION_ERROR', 'Git version too old (worktree requires git 2.5+)', {
|
||||
suggestion: 'Upgrade git to version 2.5 or newer'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect base branch
|
||||
function detectBaseBranch(cwd) {
|
||||
const branches = ['dev', 'develop', 'main', 'master'];
|
||||
for (const branch of branches) {
|
||||
const local = git(`show-ref --verify --quiet refs/heads/${branch}`, { silent: true, cwd });
|
||||
if (local.success) return branch;
|
||||
const remote = git(`show-ref --verify --quiet refs/remotes/origin/${branch}`, { silent: true, cwd });
|
||||
if (remote.success) return branch;
|
||||
}
|
||||
return 'main'; // fallback
|
||||
}
|
||||
|
||||
// Find the topmost superproject by walking up the directory tree
|
||||
// This handles submodules within monorepos - worktrees go to the root monorepo
|
||||
// Safety limit prevents infinite loops in edge cases (max 10 levels deep)
|
||||
const MAX_SUPERPROJECT_DEPTH = 10;
|
||||
|
||||
function findTopmostSuperproject(gitRoot) {
|
||||
let current = gitRoot;
|
||||
let topmost = gitRoot;
|
||||
let depth = 0;
|
||||
|
||||
// Keep walking up while we find superprojects (with safety limit)
|
||||
while (depth < MAX_SUPERPROJECT_DEPTH) {
|
||||
const result = git('rev-parse --show-superproject-working-tree', { silent: true, cwd: current });
|
||||
if (!result.success || !result.output) {
|
||||
break; // No more superprojects above
|
||||
}
|
||||
topmost = result.output;
|
||||
current = result.output;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return topmost;
|
||||
}
|
||||
|
||||
// Validate that a path can be used as worktree root (exists or can be created)
|
||||
function validateWorktreeRoot(rootPath) {
|
||||
if (typeof rootPath !== 'string' || rootPath.trim().length === 0) {
|
||||
return { valid: false, error: 'Worktree root path is empty' };
|
||||
}
|
||||
if (/[\0\r\n]/.test(rootPath)) {
|
||||
return { valid: false, error: 'Worktree root contains invalid control characters' };
|
||||
}
|
||||
const resolved = path.resolve(rootPath);
|
||||
|
||||
// Check if path exists and is a directory
|
||||
if (fs.existsSync(resolved)) {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
return { valid: false, error: `Path exists but is not a directory: ${resolved}` };
|
||||
}
|
||||
return { valid: true, path: resolved };
|
||||
}
|
||||
|
||||
// Check if parent directory exists (we can create the worktree dir)
|
||||
const parent = path.dirname(resolved);
|
||||
if (fs.existsSync(parent)) {
|
||||
const parentStat = fs.statSync(parent);
|
||||
if (!parentStat.isDirectory()) {
|
||||
return { valid: false, error: `Parent path is not a directory: ${parent}` };
|
||||
}
|
||||
return { valid: true, path: resolved };
|
||||
}
|
||||
|
||||
// Parent doesn't exist - check if grandparent exists (allows mkdir -p one level)
|
||||
const grandparent = path.dirname(parent);
|
||||
if (fs.existsSync(grandparent)) {
|
||||
return { valid: true, path: resolved };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Cannot create worktree directory: parent path does not exist: ${parent}` };
|
||||
}
|
||||
|
||||
// Determine the worktree root directory with priority:
|
||||
// 1. Explicit --worktree-root flag (Claude's decision)
|
||||
// 2. WORKTREE_ROOT env var (explicit override)
|
||||
// 3. Topmost superproject's worktrees/ (for submodules)
|
||||
// 4. Monorepo: worktrees/ inside repo (keeps related worktrees together)
|
||||
// 5. Standalone: sibling worktrees/ (avoids polluting repo)
|
||||
function getWorktreeRoot(gitRoot, isMonorepo, explicitRoot = null) {
|
||||
// Priority 0: Explicit --worktree-root flag (Claude's decision)
|
||||
if (explicitRoot) {
|
||||
const validation = validateWorktreeRoot(explicitRoot);
|
||||
if (!validation.valid) {
|
||||
outputError('INVALID_WORKTREE_ROOT', validation.error, {
|
||||
suggestion: 'Provide a valid directory path that exists or can be created'
|
||||
});
|
||||
}
|
||||
return { dir: validation.path, source: '--worktree-root flag' };
|
||||
}
|
||||
|
||||
// Priority 1: Environment variable override
|
||||
const envRoot = process.env.WORKTREE_ROOT;
|
||||
if (envRoot) {
|
||||
const validation = validateWorktreeRoot(envRoot);
|
||||
if (!validation.valid) {
|
||||
outputError('INVALID_WORKTREE_ROOT', validation.error, {
|
||||
suggestion: 'Fix WORKTREE_ROOT env var or unset it'
|
||||
});
|
||||
}
|
||||
return { dir: validation.path, source: 'WORKTREE_ROOT env' };
|
||||
}
|
||||
|
||||
// Priority 2: Check for superproject (we might be in a submodule)
|
||||
const topmostRoot = findTopmostSuperproject(gitRoot);
|
||||
if (topmostRoot !== gitRoot) {
|
||||
return {
|
||||
dir: path.join(topmostRoot, 'worktrees'),
|
||||
source: `superproject (${path.basename(topmostRoot)})`
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 3: Monorepo - use worktrees/ inside the repo
|
||||
// Keeps all project worktrees organized together within the monorepo
|
||||
if (isMonorepo) {
|
||||
return { dir: path.join(gitRoot, 'worktrees'), source: 'monorepo internal' };
|
||||
}
|
||||
|
||||
// Priority 4: Standalone repos - use sibling worktrees/
|
||||
// Avoids polluting the repo with worktree directories
|
||||
return { dir: path.join(path.dirname(gitRoot), 'worktrees'), source: 'sibling directory' };
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
function checkDirtyState() {
|
||||
const diff = git('diff --quiet', { silent: true });
|
||||
const diffCached = git('diff --cached --quiet', { silent: true });
|
||||
return !diff.success || !diffCached.success;
|
||||
}
|
||||
|
||||
// Get dirty state details
|
||||
function getDirtyStateDetails() {
|
||||
const status = git('status --porcelain', { silent: true });
|
||||
if (!status.success) return null;
|
||||
const lines = status.output.split('\n').filter(Boolean);
|
||||
const modified = lines.filter(l => l.startsWith(' M') || l.startsWith('M ')).length;
|
||||
const staged = lines.filter(l => l.startsWith('A ') || l.startsWith('M ') || l.startsWith('D ')).length;
|
||||
const untracked = lines.filter(l => l.startsWith('??')).length;
|
||||
return { modified, staged, untracked, total: lines.length };
|
||||
}
|
||||
|
||||
// Parse .gitmodules for monorepo detection
|
||||
function parseGitModules(gitRoot) {
|
||||
const modulesPath = path.join(gitRoot, '.gitmodules');
|
||||
if (!fs.existsSync(modulesPath)) return [];
|
||||
|
||||
const content = fs.readFileSync(modulesPath, 'utf-8');
|
||||
const projects = [];
|
||||
const pathRegex = /path\s*=\s*(.+)/g;
|
||||
let match;
|
||||
while ((match = pathRegex.exec(content)) !== null) {
|
||||
const projectPath = match[1].trim();
|
||||
projects.push({
|
||||
path: projectPath,
|
||||
name: path.basename(projectPath)
|
||||
});
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
// Find .env files
|
||||
function findEnvFiles(dir) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
return files.filter(f => {
|
||||
if (!f.startsWith('.env')) return false;
|
||||
const fullPath = path.join(dir, f);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Find .env template files (*.example)
|
||||
function findEnvTemplates(dir) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
return files.filter(f => {
|
||||
if (!f.startsWith('.env') || !f.endsWith('.example')) return false;
|
||||
const fullPath = path.join(dir, f);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Copy env templates to worktree (strips .example suffix)
|
||||
function copyEnvTemplates(srcDir, destDir) {
|
||||
const templates = findEnvTemplates(srcDir);
|
||||
const copied = [];
|
||||
const warnings = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
const srcPath = path.join(srcDir, template);
|
||||
const destName = template.replace(/\.example$/, '');
|
||||
const destPath = path.join(destDir, destName);
|
||||
|
||||
try {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
copied.push({ from: template, to: destName });
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to copy ${template}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { copied, warnings };
|
||||
}
|
||||
|
||||
// Find matching projects
|
||||
function findMatchingProjects(projects, query) {
|
||||
const queryLower = query.toLowerCase();
|
||||
return projects.filter(p =>
|
||||
p.name.toLowerCase().includes(queryLower) ||
|
||||
p.path.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if branch is already checked out
|
||||
function isBranchCheckedOut(branchName, cwd) {
|
||||
const result = git('worktree list --porcelain', { silent: true, cwd });
|
||||
if (!result.success) return false;
|
||||
return result.output.includes(`branch refs/heads/${branchName}`);
|
||||
}
|
||||
|
||||
// Check if branch exists
|
||||
function branchExists(branchName, cwd) {
|
||||
const local = git(`show-ref --verify --quiet refs/heads/${branchName}`, { silent: true, cwd });
|
||||
if (local.success) return 'local';
|
||||
const remote = git(`show-ref --verify --quiet refs/remotes/origin/${branchName}`, { silent: true, cwd });
|
||||
if (remote.success) return 'remote';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanitize feature name to valid branch name
|
||||
function sanitizeFeatureName(name, preserveCase = false) {
|
||||
const raw = String(name || '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
// Keep ASCII branch names; drop diacritics first for better readability.
|
||||
let ascii = raw
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
// When preserveCase is true (--no-prefix), keep original casing
|
||||
if (!preserveCase) ascii = ascii.toLowerCase();
|
||||
|
||||
// preserveCase (--no-prefix): preserve `/` for multi-segment branch names (e.g. kai/feat/foo)
|
||||
// Security: reject `..` path components to prevent directory traversal
|
||||
if (preserveCase && ascii.split('/').some(seg => seg === '..')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ascii = ascii
|
||||
.replace(preserveCase ? /[^a-zA-Z0-9/.-]/g : /[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
if (preserveCase) {
|
||||
// Clean up slash sequences: collapse consecutive, trim leading/trailing
|
||||
ascii = ascii
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/|\/$/g, '');
|
||||
// Remove dashes adjacent to slashes (e.g. -/- becomes /)
|
||||
ascii = ascii
|
||||
.replace(/-?\/-?/g, '/');
|
||||
}
|
||||
|
||||
// Multi-segment names need longer limit to accommodate user/type/feature patterns
|
||||
ascii = ascii.slice(0, preserveCase ? 80 : 50);
|
||||
|
||||
if (ascii) return ascii;
|
||||
|
||||
// If input had alphanumeric Unicode but collapsed to empty, keep deterministic fallback.
|
||||
if (/[\p{L}\p{N}]/u.test(raw)) {
|
||||
const hash = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 8);
|
||||
return `feature-${hash}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Flatten branch name segments for filesystem-safe directory naming
|
||||
function flattenForDirectoryName(branchSegment) {
|
||||
return branchSegment.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
// COMMANDS
|
||||
|
||||
function cmdInfo() {
|
||||
const gitRoot = checkGitRepo();
|
||||
checkGitVersion();
|
||||
|
||||
const projects = parseGitModules(gitRoot);
|
||||
const isMonorepo = projects.length > 0;
|
||||
const baseBranch = detectBaseBranch(gitRoot);
|
||||
const dirtyState = checkDirtyState();
|
||||
const dirtyDetails = dirtyState ? getDirtyStateDetails() : null;
|
||||
const envFiles = findEnvFiles(gitRoot);
|
||||
|
||||
// Get worktree root info (shows where worktrees will be created)
|
||||
const worktreeRoot = getWorktreeRoot(gitRoot, isMonorepo);
|
||||
|
||||
// For monorepo, also check each project for env files
|
||||
const projectEnvFiles = {};
|
||||
if (isMonorepo) {
|
||||
projects.forEach(p => {
|
||||
const projectDir = path.join(gitRoot, p.path);
|
||||
if (fs.existsSync(projectDir)) {
|
||||
const files = findEnvFiles(projectDir);
|
||||
if (files.length > 0) {
|
||||
projectEnvFiles[p.name] = files;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
output({
|
||||
info: true,
|
||||
repoType: isMonorepo ? 'monorepo' : 'standalone',
|
||||
gitRoot,
|
||||
baseBranch,
|
||||
worktreeRoot: worktreeRoot.dir,
|
||||
worktreeRootSource: worktreeRoot.source,
|
||||
projects: isMonorepo ? projects : [],
|
||||
envFiles,
|
||||
projectEnvFiles: isMonorepo ? projectEnvFiles : {},
|
||||
dirtyState,
|
||||
dirtyDetails
|
||||
});
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
checkGitRepo();
|
||||
const result = git('worktree list', { silent: true });
|
||||
if (!result.success) {
|
||||
outputError('WORKTREE_LIST_ERROR', 'Failed to list worktrees', {
|
||||
suggestion: 'Ensure you are in a git repository'
|
||||
});
|
||||
}
|
||||
|
||||
const worktrees = result.output.split('\n').filter(Boolean).map(line => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
path: parts[0],
|
||||
commit: parts[1],
|
||||
branch: parts[2]?.replace(/[\[\]]/g, '') || 'detached'
|
||||
};
|
||||
});
|
||||
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify({ success: true, worktrees }, null, 2));
|
||||
} else {
|
||||
console.log('\n📂 Existing worktrees:');
|
||||
worktrees.forEach(w => {
|
||||
console.log(` ${w.path}`);
|
||||
console.log(` Branch: ${w.branch} (${w.commit.slice(0, 7)})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cmdCreate() {
|
||||
const gitRoot = checkGitRepo();
|
||||
checkGitVersion();
|
||||
|
||||
const projects = parseGitModules(gitRoot);
|
||||
const isMonorepo = projects.length > 0;
|
||||
const warnings = [];
|
||||
if (branchPrefixWarning) warnings.push(branchPrefixWarning);
|
||||
const safeEnvFilesToCopy = [];
|
||||
if (envFilesToCopy.length > 0) {
|
||||
envFilesToCopy.forEach(envFile => {
|
||||
if (!isSafeEnvFileName(envFile)) {
|
||||
warnings.push(`Skipped unsafe env file entry: ${envFile}`);
|
||||
return;
|
||||
}
|
||||
if (!safeEnvFilesToCopy.includes(envFile)) {
|
||||
safeEnvFilesToCopy.push(envFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Parse arguments based on repo type
|
||||
// Monorepo: create <project> <feature>
|
||||
// Standalone: create <feature>
|
||||
let project, feature;
|
||||
if (isMonorepo) {
|
||||
project = arg1;
|
||||
feature = arg2;
|
||||
if (!project || !feature) {
|
||||
outputError('MISSING_ARGS', 'Both project and feature are required for monorepo', {
|
||||
suggestion: 'Usage: node worktree.cjs create <project> <feature> --prefix <type>',
|
||||
availableProjects: projects.map(p => p.name)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
feature = arg1;
|
||||
if (!feature) {
|
||||
outputError('MISSING_FEATURE', 'Feature name is required', {
|
||||
suggestion: 'Usage: node worktree.cjs create <feature> --prefix <type>'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check dirty state
|
||||
if (checkDirtyState()) {
|
||||
const details = getDirtyStateDetails();
|
||||
warnings.push(`Uncommitted changes: ${details.modified} modified, ${details.staged} staged, ${details.untracked} untracked`);
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
let workDir = gitRoot;
|
||||
let projectPath = '';
|
||||
let projectName = '';
|
||||
|
||||
if (isMonorepo) {
|
||||
const matches = findMatchingProjects(projects, project);
|
||||
|
||||
if (matches.length === 0) {
|
||||
outputError('PROJECT_NOT_FOUND', `Project "${project}" not found`, {
|
||||
suggestion: 'Check available projects with: node worktree.cjs info',
|
||||
availableProjects: projects.map(p => p.name)
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
outputError('MULTIPLE_PROJECTS_MATCH', `Multiple projects match "${project}"`, {
|
||||
suggestion: 'Use AskUserQuestion to let user select one',
|
||||
matchingProjects: matches.map(p => ({ name: p.name, path: p.path }))
|
||||
});
|
||||
}
|
||||
|
||||
projectPath = matches[0].path;
|
||||
projectName = matches[0].name;
|
||||
workDir = path.join(gitRoot, projectPath);
|
||||
|
||||
if (!fs.existsSync(workDir)) {
|
||||
outputError('PROJECT_DIR_NOT_FOUND', `Project directory not found: ${workDir}`, {
|
||||
suggestion: 'Initialize submodules: git submodule update --init'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize feature name
|
||||
const sanitizedFeature = sanitizeFeatureName(feature, noPrefix);
|
||||
if (!sanitizedFeature) {
|
||||
outputError('INVALID_FEATURE_NAME', 'Feature name became empty after sanitization', {
|
||||
suggestion: 'Use letters/numbers in feature name (example: "login-validation")'
|
||||
});
|
||||
}
|
||||
const expectedFeature = noPrefix ? feature.replace(/\s+/g, '-') : feature.toLowerCase().replace(/\s+/g, '-');
|
||||
if (sanitizedFeature !== expectedFeature) {
|
||||
warnings.push(`Feature name sanitized: "${feature}" → "${sanitizedFeature}"`);
|
||||
}
|
||||
|
||||
// Create branch name — --no-prefix uses sanitized feature as-is
|
||||
const branchName = noPrefix ? sanitizedFeature : `${branchPrefix}/${sanitizedFeature}`;
|
||||
|
||||
// Detect base branch
|
||||
const baseBranch = detectBaseBranch(workDir);
|
||||
|
||||
// Check if branch already checked out
|
||||
if (isBranchCheckedOut(branchName, workDir)) {
|
||||
outputError('BRANCH_CHECKED_OUT', `Branch "${branchName}" is already checked out in another worktree`, {
|
||||
suggestion: 'Use a different feature name or remove the existing worktree'
|
||||
});
|
||||
}
|
||||
|
||||
// Determine worktree path using smart root detection
|
||||
// explicitWorktreeRoot comes from --worktree-root flag (Claude's decision)
|
||||
const worktreeRoot = getWorktreeRoot(gitRoot, isMonorepo, explicitWorktreeRoot);
|
||||
const worktreesDir = worktreeRoot.dir;
|
||||
|
||||
// Build worktree name: always include repo name for clarity
|
||||
// Flatten slashes to dashes for filesystem-safe directory names
|
||||
const repoName = path.basename(gitRoot);
|
||||
const flatFeature = flattenForDirectoryName(sanitizedFeature);
|
||||
const worktreeName = isMonorepo
|
||||
? `${projectName}-${flatFeature}`
|
||||
: `${repoName}-${flatFeature}`;
|
||||
|
||||
const worktreePath = path.join(worktreesDir, worktreeName);
|
||||
|
||||
// Check if worktree already exists
|
||||
if (fs.existsSync(worktreePath)) {
|
||||
outputError('WORKTREE_EXISTS', `Worktree already exists: ${worktreePath}`, {
|
||||
suggestion: `To use: cd ${worktreePath} && claude\nTo remove: git worktree remove ${worktreePath}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if branch exists
|
||||
const branchStatus = branchExists(branchName, workDir);
|
||||
|
||||
// Dry-run mode: show what would be done
|
||||
if (dryRun) {
|
||||
output({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
message: 'Dry run - no changes made',
|
||||
wouldCreate: {
|
||||
worktreePath,
|
||||
worktreeRootSource: worktreeRoot.source,
|
||||
branch: branchName,
|
||||
baseBranch,
|
||||
branchExists: !!branchStatus,
|
||||
project: isMonorepo ? projectName : null,
|
||||
envFilesToCopy: safeEnvFilesToCopy.length > 0 ? safeEnvFilesToCopy : undefined
|
||||
},
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create worktrees directory
|
||||
try {
|
||||
fs.mkdirSync(worktreesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
outputError('MKDIR_FAILED', `Failed to create worktrees directory: ${worktreesDir}`, {
|
||||
suggestion: 'Check write permissions'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch remote branch if needed
|
||||
if (branchStatus === 'remote') {
|
||||
const fetchResult = git(`fetch origin ${branchName}`, { silent: true, cwd: workDir });
|
||||
if (!fetchResult.success) {
|
||||
outputError('FETCH_FAILED', `Failed to fetch branch from remote: ${branchName}`, {
|
||||
suggestion: 'Check network connection and remote repository access'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
let createResult;
|
||||
if (branchStatus) {
|
||||
createResult = git(`worktree add "${worktreePath}" ${branchName}`, { cwd: workDir });
|
||||
} else {
|
||||
createResult = git(`worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: workDir });
|
||||
}
|
||||
|
||||
if (!createResult.success) {
|
||||
outputError('WORKTREE_CREATE_FAILED', `Failed to create worktree`, {
|
||||
suggestion: createResult.stderr || createResult.error,
|
||||
gitError: createResult.stderr
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-copy env templates (.env*.example → .env*)
|
||||
const sourceDir = isMonorepo ? workDir : gitRoot;
|
||||
const envResult = copyEnvTemplates(sourceDir, worktreePath);
|
||||
envResult.warnings.forEach(w => warnings.push(w));
|
||||
|
||||
// Also copy explicitly specified env files (legacy --env flag support)
|
||||
const envFilesCopied = envResult.copied.map(c => c.to);
|
||||
if (safeEnvFilesToCopy.length > 0) {
|
||||
safeEnvFilesToCopy.forEach(envFile => {
|
||||
const sourcePath = path.join(sourceDir, envFile);
|
||||
const destPath = path.join(worktreePath, envFile);
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
try {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
if (!envFilesCopied.includes(envFile)) {
|
||||
envFilesCopied.push(envFile);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to copy ${envFile}: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`Env file not found: ${envFile}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
output({
|
||||
success: true,
|
||||
message: 'Worktree created successfully!',
|
||||
worktreePath,
|
||||
worktreeRootSource: worktreeRoot.source,
|
||||
branch: branchName,
|
||||
baseBranch,
|
||||
project: isMonorepo ? projectName : null,
|
||||
envFilesCopied,
|
||||
envTemplatesCopied: envResult.copied,
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function cmdRemove() {
|
||||
if (!arg1) {
|
||||
outputError('MISSING_WORKTREE', 'Worktree name or path is required', {
|
||||
suggestion: 'Usage: node worktree.cjs remove <name-or-path>\nUse "node worktree.cjs list" to see available worktrees'
|
||||
});
|
||||
}
|
||||
|
||||
const gitRoot = checkGitRepo();
|
||||
checkGitVersion();
|
||||
|
||||
// Get list of worktrees
|
||||
const result = git('worktree list --porcelain', { silent: true });
|
||||
if (!result.success) {
|
||||
outputError('WORKTREE_LIST_ERROR', 'Failed to list worktrees');
|
||||
}
|
||||
|
||||
// Parse worktrees
|
||||
const worktrees = [];
|
||||
let current = {};
|
||||
result.output.split('\n').forEach(line => {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current.path) worktrees.push(current);
|
||||
current = { path: line.replace('worktree ', '') };
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.replace('branch refs/heads/', '');
|
||||
}
|
||||
});
|
||||
if (current.path) worktrees.push(current);
|
||||
|
||||
// Find matching worktree
|
||||
const searchTerm = arg1.toLowerCase();
|
||||
const removable = worktrees.filter(w => !w.path.includes('.git/'));
|
||||
const exactMatches = removable.filter(w => {
|
||||
const name = path.basename(w.path).toLowerCase();
|
||||
const fullPath = w.path.toLowerCase();
|
||||
const branch = (w.branch || '').toLowerCase();
|
||||
return name === searchTerm || fullPath === searchTerm || branch === searchTerm;
|
||||
});
|
||||
const prefixMatches = removable.filter(w => {
|
||||
const name = path.basename(w.path).toLowerCase();
|
||||
const fullPath = w.path.toLowerCase();
|
||||
const branch = (w.branch || '').toLowerCase();
|
||||
return name.startsWith(searchTerm) || fullPath.startsWith(searchTerm) || branch.startsWith(searchTerm);
|
||||
});
|
||||
const containsMatches = removable.filter(w => {
|
||||
const name = path.basename(w.path).toLowerCase();
|
||||
const fullPath = w.path.toLowerCase();
|
||||
const branch = (w.branch || '').toLowerCase();
|
||||
return name.includes(searchTerm) || fullPath.includes(searchTerm) || branch.includes(searchTerm);
|
||||
});
|
||||
|
||||
let removableMatches = exactMatches;
|
||||
if (removableMatches.length === 0) {
|
||||
removableMatches = prefixMatches;
|
||||
}
|
||||
if (removableMatches.length === 0 && searchTerm.length >= 4) {
|
||||
removableMatches = containsMatches;
|
||||
}
|
||||
|
||||
if (removableMatches.length === 0) {
|
||||
outputError('WORKTREE_NOT_FOUND', `No worktree matching "${arg1}" found`, {
|
||||
suggestion: 'Use "node worktree.cjs list" to see available worktrees',
|
||||
availableWorktrees: removable.map(w => path.basename(w.path))
|
||||
});
|
||||
}
|
||||
|
||||
if (removableMatches.length > 1) {
|
||||
outputError('MULTIPLE_WORKTREES_MATCH', `Multiple worktrees match "${arg1}"`, {
|
||||
suggestion: 'Be more specific or use full path',
|
||||
matchingWorktrees: removableMatches.map(w => ({ name: path.basename(w.path), path: w.path, branch: w.branch }))
|
||||
});
|
||||
}
|
||||
|
||||
const worktree = removableMatches[0];
|
||||
const worktreePath = worktree.path;
|
||||
const branchName = worktree.branch;
|
||||
|
||||
// Dry-run mode
|
||||
if (dryRun) {
|
||||
output({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
message: 'Dry run - no changes made',
|
||||
wouldRemove: {
|
||||
worktreePath,
|
||||
branch: branchName,
|
||||
deleteBranch: !!branchName
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove worktree
|
||||
const removeResult = git(`worktree remove "${worktreePath}" --force`, { silent: true });
|
||||
if (!removeResult.success) {
|
||||
outputError('WORKTREE_REMOVE_FAILED', `Failed to remove worktree: ${worktreePath}`, {
|
||||
suggestion: removeResult.stderr || 'Check if the worktree has uncommitted changes',
|
||||
gitError: removeResult.stderr
|
||||
});
|
||||
}
|
||||
|
||||
// Delete branch if it exists
|
||||
let branchDeleted = false;
|
||||
let branchDeleteWarning = null;
|
||||
if (branchName) {
|
||||
const deleteResult = git(`branch -d "${branchName}"`, { silent: true });
|
||||
if (deleteResult.success) {
|
||||
branchDeleted = true;
|
||||
} else {
|
||||
branchDeleteWarning = `Branch kept: ${branchName} (${deleteResult.stderr || 'not fully merged'})`;
|
||||
}
|
||||
}
|
||||
|
||||
output({
|
||||
success: true,
|
||||
message: 'Worktree removed successfully!',
|
||||
removedPath: worktreePath,
|
||||
branchDeleted: branchDeleted ? branchName : null,
|
||||
branchKept: !branchDeleted && branchName ? branchName : null,
|
||||
warnings: branchDeleteWarning ? [branchDeleteWarning] : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
const help = `Git Worktree Manager for ClaudeKit
|
||||
|
||||
Usage: node worktree.cjs <command> [options]
|
||||
|
||||
Commands:
|
||||
create <project> <feature> Create a new worktree (project optional for standalone)
|
||||
remove <name-or-path> Remove a worktree and its branch
|
||||
info Get repo info (type, projects, env files)
|
||||
list List existing worktrees
|
||||
|
||||
Options:
|
||||
--prefix <type> Branch prefix (feat|fix|refactor|docs|test|chore|perf)
|
||||
--worktree-root <path> Explicit worktree directory
|
||||
--json Output in JSON format for LLM consumption
|
||||
--env <files> Comma-separated list of .env files to copy (legacy)
|
||||
--dry-run Show what would be done without executing
|
||||
--no-prefix Skip branch prefix and preserve original case
|
||||
--help, -h Show this help message`;
|
||||
console.log(help);
|
||||
}
|
||||
|
||||
// Main
|
||||
function main() {
|
||||
if (command === '--help' || command === '-h' || command === 'help') {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'create':
|
||||
cmdCreate();
|
||||
break;
|
||||
case 'remove':
|
||||
cmdRemove();
|
||||
break;
|
||||
case 'info':
|
||||
cmdInfo();
|
||||
break;
|
||||
case 'list':
|
||||
cmdList();
|
||||
break;
|
||||
default:
|
||||
outputError('UNKNOWN_COMMAND', `Unknown command: ${command || '(none)'}`, {
|
||||
suggestion: 'Available commands: create, remove, info, list. Use --help for usage.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
857
.opencode/skills/worktree/scripts/worktree.test.cjs
Executable file
857
.opencode/skills/worktree/scripts/worktree.test.cjs
Executable file
@@ -0,0 +1,857 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test suite for worktree.cjs
|
||||
* Run: node .claude/skills/worktree/scripts/worktree.test.cjs
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCRIPT_PATH = path.join(__dirname, 'worktree.cjs');
|
||||
const STANDALONE_DIR = path.dirname(path.dirname(__dirname)); // worktree dir
|
||||
const MONOREPO_DIR = '/home/kai/claudekit';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const results = [];
|
||||
|
||||
// Test helper
|
||||
function run(args, options = {}) {
|
||||
const cwd = options.cwd || STANDALONE_DIR;
|
||||
try {
|
||||
const output = execSync(`node "${SCRIPT_PATH}" ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
return { success: true, output: output.trim(), exitCode: 0 };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: error.stdout?.toString().trim() || '',
|
||||
stderr: error.stderr?.toString().trim() || '',
|
||||
exitCode: error.status || 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
results.push({ name, status: 'PASS' });
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
results.push({ name, status: 'FAIL', error: error.message });
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertJSON(str) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON: ${str.slice(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INFO COMMAND TESTS
|
||||
// ============================================
|
||||
console.log('\n📋 INFO Command Tests');
|
||||
|
||||
test('info returns valid JSON', () => {
|
||||
const result = run('info --json');
|
||||
assert(result.success, 'Command should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.info === true, 'Should have info: true');
|
||||
});
|
||||
|
||||
test('info detects repo type', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(['standalone', 'monorepo'].includes(json.repoType), 'Should detect repo type');
|
||||
});
|
||||
|
||||
test('info detects base branch', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.baseBranch, 'Should detect base branch');
|
||||
assert(['dev', 'develop', 'main', 'master'].includes(json.baseBranch), 'Should be valid branch');
|
||||
});
|
||||
|
||||
test('info finds env files', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(Array.isArray(json.envFiles), 'Should have envFiles array');
|
||||
});
|
||||
|
||||
test('info detects dirty state', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(typeof json.dirtyState === 'boolean', 'Should have dirtyState boolean');
|
||||
});
|
||||
|
||||
test('info detects monorepo from monorepo root', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return; // Skip if not available
|
||||
const result = run('info --json', { cwd: MONOREPO_DIR });
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.repoType === 'monorepo', 'Should detect monorepo');
|
||||
assert(json.projects.length > 0, 'Should have projects');
|
||||
});
|
||||
|
||||
test('monorepo uses internal worktrees directory', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return; // Skip if not available
|
||||
const result = run('info --json', { cwd: MONOREPO_DIR });
|
||||
const json = assertJSON(result.output);
|
||||
// Monorepo should use worktrees/ inside the repo, not sibling
|
||||
assert(json.worktreeRoot === path.join(MONOREPO_DIR, 'worktrees'),
|
||||
`Expected ${path.join(MONOREPO_DIR, 'worktrees')}, got ${json.worktreeRoot}`);
|
||||
assert(json.worktreeRootSource === 'monorepo internal',
|
||||
`Expected 'monorepo internal', got ${json.worktreeRootSource}`);
|
||||
});
|
||||
|
||||
test('info returns text output without --json', () => {
|
||||
const result = run('info');
|
||||
assert(result.success, 'Command should succeed');
|
||||
assert(result.output.includes('Repository Info'), 'Should have text output');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// LIST COMMAND TESTS
|
||||
// ============================================
|
||||
console.log('\n📂 LIST Command Tests');
|
||||
|
||||
test('list returns valid JSON', () => {
|
||||
const result = run('list --json');
|
||||
assert(result.success, 'Command should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.success === true, 'Should have success: true');
|
||||
assert(Array.isArray(json.worktrees), 'Should have worktrees array');
|
||||
});
|
||||
|
||||
test('list worktrees have required fields', () => {
|
||||
const result = run('list --json');
|
||||
const json = assertJSON(result.output);
|
||||
if (json.worktrees.length > 0) {
|
||||
const wt = json.worktrees[0];
|
||||
assert(wt.path, 'Worktree should have path');
|
||||
assert(wt.commit, 'Worktree should have commit');
|
||||
assert(wt.branch, 'Worktree should have branch');
|
||||
}
|
||||
});
|
||||
|
||||
test('list returns text output without --json', () => {
|
||||
const result = run('list');
|
||||
assert(result.success, 'Command should succeed');
|
||||
assert(result.output.includes('worktrees'), 'Should have text output');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// CREATE COMMAND TESTS
|
||||
// ============================================
|
||||
console.log('\n🆕 CREATE Command Tests');
|
||||
|
||||
test('create requires feature name', () => {
|
||||
const result = run('create --json');
|
||||
assert(!result.success, 'Should fail without feature');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'MISSING_FEATURE', 'Should have MISSING_FEATURE error');
|
||||
});
|
||||
|
||||
test('create dry-run does not create worktree', () => {
|
||||
const result = run('create test-dry-run --prefix feat --dry-run --json');
|
||||
assert(result.success, 'Dry-run should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.dryRun === true, 'Should have dryRun: true');
|
||||
assert(json.wouldCreate, 'Should have wouldCreate object');
|
||||
});
|
||||
|
||||
test('create dry-run shows correct branch name', () => {
|
||||
const result = run('create my-feature --prefix fix --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch === 'fix/my-feature', 'Branch should be fix/my-feature');
|
||||
});
|
||||
|
||||
test('create sanitizes feature name - spaces', () => {
|
||||
const result = run('create "my cool feature" --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.includes('my-cool-feature'), 'Should sanitize spaces');
|
||||
});
|
||||
|
||||
test('create sanitizes feature name - uppercase', () => {
|
||||
const result = run('create "MyFeature" --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.includes('myfeature'), 'Should lowercase');
|
||||
});
|
||||
|
||||
test('create sanitizes feature name - special chars', () => {
|
||||
const result = run('create "feat@#$test" --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(!json.wouldCreate.branch.includes('@'), 'Should remove special chars');
|
||||
});
|
||||
|
||||
test('create respects --prefix flag', () => {
|
||||
const prefixes = ['feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'perf'];
|
||||
for (const prefix of prefixes) {
|
||||
const result = run(`create test-${prefix} --prefix ${prefix} --dry-run --json`);
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.startsWith(`${prefix}/`), `Should use ${prefix} prefix`);
|
||||
}
|
||||
});
|
||||
|
||||
test('create shows base branch', () => {
|
||||
const result = run('create test-base --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.baseBranch, 'Should show base branch');
|
||||
});
|
||||
|
||||
test('create shows worktree path', () => {
|
||||
const result = run('create test-path --dry-run --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.worktreePath, 'Should show worktree path');
|
||||
assert(json.wouldCreate.worktreePath.includes('worktrees'), 'Path should include worktrees dir');
|
||||
});
|
||||
|
||||
test('create in monorepo requires project', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
const result = run('create --json', { cwd: MONOREPO_DIR });
|
||||
assert(!result.success, 'Should fail without project in monorepo');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'MISSING_ARGS', 'Should have MISSING_ARGS error');
|
||||
});
|
||||
|
||||
test('create in monorepo with project works', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
const result = run('create engineer test-mono --prefix feat --dry-run --json', { cwd: MONOREPO_DIR });
|
||||
assert(result.success, 'Should succeed with project');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.project === 'claudekit-engineer', 'Should detect project');
|
||||
});
|
||||
|
||||
test('create detects invalid project', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
const result = run('create nonexistent test-invalid --json', { cwd: MONOREPO_DIR });
|
||||
assert(!result.success, 'Should fail with invalid project');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'PROJECT_NOT_FOUND', 'Should have PROJECT_NOT_FOUND error');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// REMOVE COMMAND TESTS
|
||||
// ============================================
|
||||
console.log('\n🗑️ REMOVE Command Tests');
|
||||
|
||||
test('remove requires worktree name', () => {
|
||||
const result = run('remove --json');
|
||||
assert(!result.success, 'Should fail without name');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'MISSING_WORKTREE', 'Should have MISSING_WORKTREE error');
|
||||
});
|
||||
|
||||
test('remove dry-run does not remove worktree', () => {
|
||||
// First get a worktree name from list
|
||||
const listResult = run('list --json');
|
||||
const listJson = assertJSON(listResult.output);
|
||||
const removable = listJson.worktrees.find(w => !w.path.includes('.git/'));
|
||||
|
||||
if (removable) {
|
||||
const name = path.basename(removable.path);
|
||||
const result = run(`remove "${name}" --dry-run --json`);
|
||||
assert(result.success, 'Dry-run should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.dryRun === true, 'Should have dryRun: true');
|
||||
assert(json.wouldRemove, 'Should have wouldRemove object');
|
||||
}
|
||||
});
|
||||
|
||||
test('remove handles not found', () => {
|
||||
const result = run('remove nonexistent-worktree-xyz --json');
|
||||
assert(!result.success, 'Should fail for nonexistent');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'WORKTREE_NOT_FOUND', 'Should have WORKTREE_NOT_FOUND error');
|
||||
});
|
||||
|
||||
test('remove error includes available worktrees', () => {
|
||||
const result = run('remove nonexistent-worktree-xyz --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(Array.isArray(json.error.availableWorktrees), 'Should list available worktrees');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// AUTO-FEATURES TESTS (env templates)
|
||||
// ============================================
|
||||
console.log('\n🤖 Auto-Features Tests');
|
||||
|
||||
test('create dry-run succeeds', () => {
|
||||
const result = run('create test-env-feature --prefix feat --dry-run --json');
|
||||
assert(result.success, 'Dry-run should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.dryRun === true, 'Should have dryRun: true');
|
||||
});
|
||||
|
||||
test('create ignores unsafe --env traversal entries', () => {
|
||||
const result = run('create env-guard --prefix feat --dry-run --json --env "../.env,secrets/.env,.env.local"');
|
||||
assert(result.success, 'Dry-run should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(Array.isArray(json.warnings), 'Should include warnings');
|
||||
assert(json.warnings.some(w => w.includes('unsafe env file')), 'Should warn for unsafe env entries');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// WORKTREE ROOT DETECTION TESTS
|
||||
// ============================================
|
||||
console.log('\n📍 Worktree Root Detection Tests');
|
||||
|
||||
test('info shows worktreeRoot and worktreeRootSource', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.worktreeRoot, 'Should have worktreeRoot');
|
||||
assert(json.worktreeRootSource, 'Should have worktreeRootSource');
|
||||
assert(typeof json.worktreeRoot === 'string', 'worktreeRoot should be string');
|
||||
assert(json.worktreeRoot.includes('worktrees'), 'worktreeRoot should include worktrees');
|
||||
});
|
||||
|
||||
test('create --worktree-root overrides default location', () => {
|
||||
const customRoot = '/tmp/test-worktrees';
|
||||
const result = run(`create test-custom-root --prefix feat --dry-run --json --worktree-root "${customRoot}"`);
|
||||
assert(result.success, 'Should succeed with custom root');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.worktreePath.startsWith(customRoot), 'Path should use custom root');
|
||||
assert(json.wouldCreate.worktreeRootSource === '--worktree-root flag', 'Source should be flag');
|
||||
});
|
||||
|
||||
test('create --worktree-root with relative path resolves to absolute', () => {
|
||||
const result = run('create test-relative --prefix feat --dry-run --json --worktree-root "./custom-worktrees"');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(path.isAbsolute(json.wouldCreate.worktreePath), 'Path should be absolute');
|
||||
});
|
||||
|
||||
test('create dry-run shows worktreeRootSource', () => {
|
||||
const result = run('create test-source --prefix feat --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.worktreeRootSource, 'Should show worktreeRootSource');
|
||||
});
|
||||
|
||||
test('superproject detection in submodule', () => {
|
||||
// Test from claudekit-engineer submodule
|
||||
const submodulePath = '/home/kai/claudekit/claudekit-engineer';
|
||||
if (!fs.existsSync(submodulePath)) return;
|
||||
const result = run('info --json', { cwd: submodulePath });
|
||||
const json = assertJSON(result.output);
|
||||
// Should detect parent monorepo as superproject
|
||||
assert(json.worktreeRootSource.includes('superproject') || json.worktreeRootSource === 'monorepo root',
|
||||
'Should detect superproject or monorepo root');
|
||||
});
|
||||
|
||||
test('WORKTREE_ROOT env var overrides detection', () => {
|
||||
const envRoot = '/tmp/env-worktrees';
|
||||
try {
|
||||
const output = execSync(`WORKTREE_ROOT="${envRoot}" node "${SCRIPT_PATH}" create test-env --prefix feat --dry-run --json`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: STANDALONE_DIR,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
const json = JSON.parse(output.trim());
|
||||
assert(json.wouldCreate.worktreePath.startsWith(envRoot), 'Should use env var root');
|
||||
assert(json.wouldCreate.worktreeRootSource === 'WORKTREE_ROOT env', 'Source should be env');
|
||||
} catch (error) {
|
||||
// May fail if script path issue - skip
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid WORKTREE_ROOT env var fails safely', () => {
|
||||
const invalidRoot = '/etc/passwd';
|
||||
try {
|
||||
execSync(`WORKTREE_ROOT="${invalidRoot}" node "${SCRIPT_PATH}" info --json`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: STANDALONE_DIR,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
assert(false, 'Should fail with invalid WORKTREE_ROOT');
|
||||
} catch (error) {
|
||||
const json = assertJSON(error.stdout.toString());
|
||||
assert(json.error.code === 'INVALID_WORKTREE_ROOT', 'Should have INVALID_WORKTREE_ROOT');
|
||||
}
|
||||
});
|
||||
|
||||
test('create --worktree-root validates path existence', () => {
|
||||
// Use a deeply nested non-existent path that can't be created
|
||||
const invalidRoot = '/nonexistent/deeply/nested/path/that/does/not/exist';
|
||||
const result = run(`create test-invalid-root --prefix feat --json --worktree-root "${invalidRoot}"`);
|
||||
assert(!result.success, 'Should fail with invalid path');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'INVALID_WORKTREE_ROOT', 'Should have INVALID_WORKTREE_ROOT error');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ERROR HANDLING TESTS
|
||||
// ============================================
|
||||
console.log('\n⚠️ Error Handling Tests');
|
||||
|
||||
test('unknown command returns error', () => {
|
||||
const result = run('unknowncommand --json');
|
||||
assert(!result.success, 'Should fail');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'UNKNOWN_COMMAND', 'Should have UNKNOWN_COMMAND error');
|
||||
});
|
||||
|
||||
test('no command returns error', () => {
|
||||
const result = run('--json');
|
||||
assert(!result.success, 'Should fail');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'UNKNOWN_COMMAND', 'Should have UNKNOWN_COMMAND error');
|
||||
});
|
||||
|
||||
test('errors have suggestion field', () => {
|
||||
const result = run('create --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.suggestion, 'Error should have suggestion');
|
||||
});
|
||||
|
||||
test('success commands return exit code 0', () => {
|
||||
const result = run('info --json');
|
||||
assert(result.exitCode === 0, 'Exit code should be 0');
|
||||
});
|
||||
|
||||
test('error commands return exit code 1', () => {
|
||||
const result = run('create --json');
|
||||
assert(result.exitCode === 1, 'Exit code should be 1');
|
||||
});
|
||||
|
||||
test('non-git directory returns error', () => {
|
||||
const result = run('info --json', { cwd: '/tmp' });
|
||||
assert(!result.success, 'Should fail in non-git dir');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'NOT_GIT_REPO', 'Should have NOT_GIT_REPO error');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: FEATURE NAME HANDLING
|
||||
// ============================================
|
||||
console.log('\n🔤 Feature Name Edge Cases');
|
||||
|
||||
test('create handles empty string feature', () => {
|
||||
const result = run('create "" --json');
|
||||
assert(!result.success, 'Should fail with empty feature');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'MISSING_FEATURE', 'Should have MISSING_FEATURE error');
|
||||
});
|
||||
|
||||
test('create handles very long feature name (truncates to 50 chars)', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
const result = run(`create "${longName}" --dry-run --json`);
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
const branchPart = json.wouldCreate.branch.split('/')[1];
|
||||
assert(branchPart.length <= 50, 'Feature part should be max 50 chars');
|
||||
});
|
||||
|
||||
test('create handles unicode characters', () => {
|
||||
const result = run('create "测试功能-тест" --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
// Unicode gets converted to dashes
|
||||
assert(!json.wouldCreate.branch.includes('测'), 'Should not contain unicode');
|
||||
});
|
||||
|
||||
test('create handles leading/trailing dashes', () => {
|
||||
const result = run('create "---feature---" --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(!json.wouldCreate.branch.endsWith('/-'), 'Should not end with dash');
|
||||
assert(!json.wouldCreate.branch.includes('//'), 'Should not have double slashes');
|
||||
});
|
||||
|
||||
test('create handles only special characters', () => {
|
||||
const result = run('create "@#$%^&*()" --dry-run --json');
|
||||
assert(!result.success, 'Should fail when sanitized feature is empty');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'INVALID_FEATURE_NAME', 'Should report invalid feature name');
|
||||
});
|
||||
|
||||
test('create handles numbers only', () => {
|
||||
const result = run('create "12345" --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.includes('12345'), 'Should keep numbers');
|
||||
});
|
||||
|
||||
test('create handles mixed case camelCase', () => {
|
||||
const result = run('create "myNewFeature" --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.includes('mynewfeature'), 'Should be lowercase');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// --no-prefix: MULTI-SEGMENT BRANCH NAMES
|
||||
// ============================================
|
||||
console.log('\n🔀 --no-prefix Multi-Segment Branch Names');
|
||||
|
||||
test('--no-prefix preserves forward slashes in branch name', () => {
|
||||
const result = run('create "dev/feat/999-test-slash-preserve" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch === 'dev/feat/999-test-slash-preserve', `Should preserve slashes, got: ${json.wouldCreate.branch}`);
|
||||
});
|
||||
|
||||
test('--no-prefix preserves case with slashes', () => {
|
||||
const result = run('create "User/Fix/MyBug" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch === 'User/Fix/MyBug', `Should preserve case and slashes, got: ${json.wouldCreate.branch}`);
|
||||
});
|
||||
|
||||
test('--no-prefix flattens slashes in worktree directory name', () => {
|
||||
const result = run('create "kai/feat/my-feature" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
// Worktree path should NOT contain nested directories from branch slashes
|
||||
const worktreeName = json.wouldCreate.worktreePath.split('/').pop();
|
||||
assert(!worktreeName.includes('/'), 'Worktree dir name should not contain slashes');
|
||||
assert(worktreeName.includes('kai-feat-my-feature'), `Should flatten slashes to dashes, got: ${worktreeName}`);
|
||||
});
|
||||
|
||||
test('--no-prefix collapses consecutive slashes', () => {
|
||||
const result = run('create "kai///feat//my-feature" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(!json.wouldCreate.branch.includes('//'), `Should not have consecutive slashes, got: ${json.wouldCreate.branch}`);
|
||||
});
|
||||
|
||||
test('--no-prefix trims leading/trailing slashes', () => {
|
||||
const result = run('create "/kai/feat/my-feature/" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(!json.wouldCreate.branch.startsWith('/'), 'Should not start with slash');
|
||||
assert(!json.wouldCreate.branch.endsWith('/'), 'Should not end with slash');
|
||||
});
|
||||
|
||||
test('--no-prefix rejects path traversal (..)', () => {
|
||||
const result = run('create "kai/../../../etc/passwd" --no-prefix --dry-run --json');
|
||||
assert(!result.success, 'Should fail with path traversal');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'INVALID_FEATURE_NAME', 'Should report invalid feature name');
|
||||
});
|
||||
|
||||
test('--no-prefix still works for simple names (no slashes)', () => {
|
||||
const result = run('create "ND-1377-cleanup-docs" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch === 'ND-1377-cleanup-docs', `Should work without slashes, got: ${json.wouldCreate.branch}`);
|
||||
});
|
||||
|
||||
test('--no-prefix preserves dots in branch names', () => {
|
||||
const result = run('create "release/v1.2.3" --no-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch === 'release/v1.2.3', `Should preserve dots, got: ${json.wouldCreate.branch}`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: PATH HANDLING
|
||||
// ============================================
|
||||
console.log('\n📁 Path Handling Edge Cases');
|
||||
|
||||
test('create handles path with spaces via --worktree-root', () => {
|
||||
const pathWithSpaces = '/tmp/my worktree dir';
|
||||
const result = run(`create test-spaces --prefix feat --dry-run --json --worktree-root "${pathWithSpaces}"`);
|
||||
assert(result.success, 'Should succeed with quoted path');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.worktreePath.includes('my worktree dir'), 'Should preserve spaces');
|
||||
});
|
||||
|
||||
test('create handles home directory expansion', () => {
|
||||
// Script uses path.resolve which doesn't expand ~, so this tests current behavior
|
||||
const result = run('create test-home --prefix feat --dry-run --json --worktree-root "~/test-worktrees"');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
// ~/test-worktrees should be resolved relative to cwd, not expanded
|
||||
assert(json.wouldCreate.worktreePath, 'Should have worktree path');
|
||||
});
|
||||
|
||||
test('create validates file path as worktree root', () => {
|
||||
// /etc/passwd exists but is a file, not directory
|
||||
const result = run('create test-file --prefix feat --json --worktree-root "/etc/passwd"');
|
||||
assert(!result.success, 'Should fail when path is file');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'INVALID_WORKTREE_ROOT', 'Should have INVALID_WORKTREE_ROOT');
|
||||
assert(json.error.message.includes('not a directory'), 'Should mention not a directory');
|
||||
});
|
||||
|
||||
test('create handles current directory as worktree root', () => {
|
||||
const result = run('create test-current --prefix feat --dry-run --json --worktree-root "."');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(path.isAbsolute(json.wouldCreate.worktreePath), 'Should resolve to absolute');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: BRANCH PREFIX HANDLING
|
||||
// ============================================
|
||||
console.log('\n🏷️ Branch Prefix Edge Cases');
|
||||
|
||||
test('create uses default prefix when --prefix missing value', () => {
|
||||
// --prefix without value should use 'feat' default
|
||||
const result = run('create test-default-prefix --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.startsWith('feat/'), 'Should default to feat');
|
||||
});
|
||||
|
||||
test('create handles invalid prefix gracefully', () => {
|
||||
// Prefix is sanitized before use.
|
||||
const result = run('create test-custom-prefix --prefix custom --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.startsWith('custom/'), 'Should use custom prefix');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: MONOREPO SCENARIOS
|
||||
// ============================================
|
||||
console.log('\n📦 Monorepo Edge Cases');
|
||||
|
||||
test('create with partial project match in monorepo', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
// 'cli' should match 'claudekit-cli'
|
||||
const result = run('create cli test-partial --prefix feat --dry-run --json', { cwd: MONOREPO_DIR });
|
||||
assert(result.success, 'Should succeed with partial match');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.project === 'claudekit-cli', 'Should find claudekit-cli');
|
||||
});
|
||||
|
||||
test('create detects multiple project matches', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
// 'claudekit' matches multiple projects
|
||||
const result = run('create claudekit test-multi --prefix feat --json', { cwd: MONOREPO_DIR });
|
||||
assert(!result.success, 'Should fail with multiple matches');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'MULTIPLE_PROJECTS_MATCH', 'Should have MULTIPLE_PROJECTS_MATCH error');
|
||||
assert(json.error.matchingProjects.length > 1, 'Should list multiple matches');
|
||||
});
|
||||
|
||||
test('info shows project env files in monorepo', () => {
|
||||
if (!fs.existsSync(MONOREPO_DIR)) return;
|
||||
const result = run('info --json', { cwd: MONOREPO_DIR });
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.projectEnvFiles !== undefined, 'Should have projectEnvFiles');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: WORKTREE REMOVAL
|
||||
// ============================================
|
||||
console.log('\n🗑️ Remove Edge Cases');
|
||||
|
||||
test('remove matches by full path', () => {
|
||||
const listResult = run('list --json');
|
||||
const listJson = assertJSON(listResult.output);
|
||||
const removable = listJson.worktrees.find(w => !w.path.includes('.git/'));
|
||||
|
||||
if (removable) {
|
||||
const result = run(`remove "${removable.path}" --dry-run --json`);
|
||||
assert(result.success, 'Should match by full path');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldRemove.worktreePath === removable.path, 'Should match exact path');
|
||||
}
|
||||
});
|
||||
|
||||
test('remove matches by branch name', () => {
|
||||
const listResult = run('list --json');
|
||||
const listJson = assertJSON(listResult.output);
|
||||
const removable = listJson.worktrees.find(w => w.branch && !w.path.includes('.git/'));
|
||||
|
||||
if (removable && removable.branch !== 'detached') {
|
||||
const branchPart = removable.branch.split('/').pop(); // Get last part of branch
|
||||
const result = run(`remove "${branchPart}" --dry-run --json`);
|
||||
// May match or have multiple matches - both are valid behaviors
|
||||
assert(result.output, 'Should have output');
|
||||
}
|
||||
});
|
||||
|
||||
test('remove is case insensitive', () => {
|
||||
const result = run('remove NONEXISTENT-WORKTREE-XYZ --json');
|
||||
assert(!result.success, 'Should fail');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.error.code === 'WORKTREE_NOT_FOUND', 'Should search case-insensitively');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: DIRTY STATE HANDLING
|
||||
// ============================================
|
||||
console.log('\n📝 Dirty State Edge Cases');
|
||||
|
||||
test('info provides dirty state details', () => {
|
||||
const result = run('info --json');
|
||||
const json = assertJSON(result.output);
|
||||
assert(typeof json.dirtyState === 'boolean', 'Should have dirtyState');
|
||||
if (json.dirtyState) {
|
||||
assert(json.dirtyDetails, 'Should have dirtyDetails when dirty');
|
||||
assert(typeof json.dirtyDetails.modified === 'number', 'Should have modified count');
|
||||
assert(typeof json.dirtyDetails.staged === 'number', 'Should have staged count');
|
||||
assert(typeof json.dirtyDetails.untracked === 'number', 'Should have untracked count');
|
||||
}
|
||||
});
|
||||
|
||||
test('create includes warning for dirty state', () => {
|
||||
// This test depends on repo state - if clean, warning won't appear
|
||||
const result = run('create test-dirty-check --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
// warnings may or may not exist depending on repo state
|
||||
if (json.warnings) {
|
||||
assert(Array.isArray(json.warnings), 'warnings should be array');
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: JSON VS TEXT OUTPUT
|
||||
// ============================================
|
||||
console.log('\n📤 Output Format Edge Cases');
|
||||
|
||||
test('info text output includes all sections', () => {
|
||||
const result = run('info');
|
||||
assert(result.success, 'Should succeed');
|
||||
assert(result.output.includes('Repository Info'), 'Should have repo info');
|
||||
assert(result.output.includes('Type:'), 'Should have type');
|
||||
assert(result.output.includes('Base branch:'), 'Should have base branch');
|
||||
assert(result.output.includes('Worktree location:'), 'Should have worktree location');
|
||||
});
|
||||
|
||||
test('list text output is readable', () => {
|
||||
const result = run('list');
|
||||
assert(result.success, 'Should succeed');
|
||||
assert(result.output.includes('worktrees'), 'Should mention worktrees');
|
||||
});
|
||||
|
||||
test('error text output is readable', () => {
|
||||
const result = run('create');
|
||||
assert(!result.success, 'Should fail');
|
||||
assert(result.stderr.includes('Error') || result.output.includes('Error'), 'Should have error text');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: EXISTING BRANCH SCENARIOS
|
||||
// ============================================
|
||||
console.log('\n🌿 Branch Existence Edge Cases');
|
||||
|
||||
test('create dry-run shows if branch exists', () => {
|
||||
const result = run('create test-branch-exist --prefix feat --dry-run --json');
|
||||
assert(result.success, 'Should succeed');
|
||||
const json = assertJSON(result.output);
|
||||
assert(typeof json.wouldCreate.branchExists === 'boolean', 'Should indicate branch existence');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EDGE CASE: CONCURRENT/RACE CONDITIONS
|
||||
// ============================================
|
||||
console.log('\n⚡ Concurrent Access Tests');
|
||||
|
||||
test('multiple info calls return consistent data', () => {
|
||||
const result1 = run('info --json');
|
||||
const result2 = run('info --json');
|
||||
assert(result1.success && result2.success, 'Both should succeed');
|
||||
const json1 = assertJSON(result1.output);
|
||||
const json2 = assertJSON(result2.output);
|
||||
assert(json1.repoType === json2.repoType, 'Repo type should be consistent');
|
||||
assert(json1.baseBranch === json2.baseBranch, 'Base branch should be consistent');
|
||||
assert(json1.worktreeRoot === json2.worktreeRoot, 'Worktree root should be consistent');
|
||||
});
|
||||
|
||||
test('list returns consistent worktree count', () => {
|
||||
const result1 = run('list --json');
|
||||
const result2 = run('list --json');
|
||||
assert(result1.success && result2.success, 'Both should succeed');
|
||||
const json1 = assertJSON(result1.output);
|
||||
const json2 = assertJSON(result2.output);
|
||||
assert(json1.worktrees.length === json2.worktrees.length, 'Worktree count should be consistent');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// USER SCENARIO: REAL-WORLD WORKFLOWS
|
||||
// ============================================
|
||||
console.log('\n👤 User Scenario Tests');
|
||||
|
||||
test('scenario: new user creates first worktree', () => {
|
||||
// Step 1: Check info
|
||||
const infoResult = run('info --json');
|
||||
assert(infoResult.success, 'Info should succeed');
|
||||
const info = assertJSON(infoResult.output);
|
||||
|
||||
// Step 2: Dry-run create
|
||||
const createResult = run('create add-login-feature --prefix feat --dry-run --json');
|
||||
assert(createResult.success, 'Create dry-run should succeed');
|
||||
const create = assertJSON(createResult.output);
|
||||
assert(create.wouldCreate.branch === 'feat/add-login-feature', 'Branch should be correctly named');
|
||||
assert(create.wouldCreate.baseBranch === info.baseBranch, 'Should use detected base branch');
|
||||
});
|
||||
|
||||
test('scenario: user fixes bug in submodule', () => {
|
||||
const submodulePath = '/home/kai/claudekit/claudekit-engineer';
|
||||
if (!fs.existsSync(submodulePath)) return;
|
||||
|
||||
// From submodule, create a fix branch
|
||||
const result = run('create fix-auth-bug --prefix fix --dry-run --json', { cwd: submodulePath });
|
||||
assert(result.success, 'Should succeed from submodule');
|
||||
const json = assertJSON(result.output);
|
||||
assert(json.wouldCreate.branch.startsWith('fix/'), 'Should have fix prefix');
|
||||
// Worktree should go to superproject
|
||||
assert(json.wouldCreate.worktreeRootSource.includes('superproject') ||
|
||||
json.wouldCreate.worktreeRootSource.includes('monorepo'),
|
||||
'Should use superproject worktrees dir');
|
||||
});
|
||||
|
||||
test('scenario: user cleans up old worktrees', () => {
|
||||
// List worktrees first
|
||||
const listResult = run('list --json');
|
||||
assert(listResult.success, 'List should succeed');
|
||||
const list = assertJSON(listResult.output);
|
||||
|
||||
// Try to remove a nonexistent worktree (simulating cleanup)
|
||||
const removeResult = run('remove old-feature-xyz --json');
|
||||
assert(!removeResult.success, 'Should fail for nonexistent');
|
||||
const remove = assertJSON(removeResult.output);
|
||||
assert(remove.error.availableWorktrees, 'Should show available worktrees for cleanup');
|
||||
});
|
||||
|
||||
test('scenario: user with WORKTREE_ROOT env var', () => {
|
||||
const customRoot = '/tmp/custom-worktrees';
|
||||
try {
|
||||
const output = execSync(
|
||||
`WORKTREE_ROOT="${customRoot}" node "${SCRIPT_PATH}" info --json`,
|
||||
{ encoding: 'utf-8', cwd: STANDALONE_DIR, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
const json = JSON.parse(output.trim());
|
||||
assert(json.worktreeRoot === customRoot, 'Should use env var');
|
||||
assert(json.worktreeRootSource === 'WORKTREE_ROOT env', 'Should indicate env source');
|
||||
} catch (error) {
|
||||
// Skip if env var handling fails
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SUMMARY
|
||||
// ============================================
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results.filter(r => r.status === 'FAIL').forEach(r => {
|
||||
console.log(` - ${r.name}: ${r.error}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ All tests passed!\n');
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user