/** * Git worktree utilities for testing * Provides helpers for creating test git repos and managing worktrees */ import * as fs from "fs"; import * as path from "path"; import { exec } from "child_process"; import { promisify } from "util"; import { Page } from "@playwright/test"; import { sanitizeBranchName, TIMEOUTS } from "../core/constants"; const execAsync = promisify(exec); // ============================================================================ // Types // ============================================================================ export interface TestRepo { path: string; cleanup: () => Promise; } export interface FeatureData { id: string; category: string; description: string; status: string; branchName?: string; worktreePath?: string; } // ============================================================================ // Path Utilities // ============================================================================ /** * Get the workspace root directory (internal use only) * Note: Also exported from project/fixtures.ts for broader use */ function getWorkspaceRoot(): string { const cwd = process.cwd(); if (cwd.includes("apps/app")) { return path.resolve(cwd, "../.."); } return cwd; } /** * Create a unique temp directory path for tests */ export function createTempDirPath(prefix: string = "temp-worktree-tests"): string { const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`; return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`); } /** * Get the expected worktree path for a branch */ export function getWorktreePath(projectPath: string, branchName: string): string { const sanitizedName = sanitizeBranchName(branchName); return path.join(projectPath, ".worktrees", sanitizedName); } // ============================================================================ // Git Repository Management // ============================================================================ /** * Create a temporary git repository for testing */ export async function createTestGitRepo(tempDir: string): Promise { // Create temp directory if it doesn't exist if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); // Initialize git repo await execAsync("git init", { cwd: tmpDir }); await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); await execAsync('git config user.name "Test User"', { cwd: tmpDir }); // Create initial commit fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n"); await execAsync("git add .", { cwd: tmpDir }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); // Create main branch explicitly await execAsync("git branch -M main", { cwd: tmpDir }); // Create .automaker directories const automakerDir = path.join(tmpDir, ".automaker"); const featuresDir = path.join(automakerDir, "features"); fs.mkdirSync(featuresDir, { recursive: true }); // Create empty categories.json to avoid ENOENT errors in tests fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]"); return { path: tmpDir, cleanup: async () => { await cleanupTestRepo(tmpDir); }, }; } /** * Cleanup a test git repository */ export async function cleanupTestRepo(repoPath: string): Promise { try { // Remove all worktrees first const { stdout } = await execAsync("git worktree list --porcelain", { cwd: repoPath, }).catch(() => ({ stdout: "" })); const worktrees = stdout .split("\n\n") .slice(1) // Skip main worktree .map((block) => { const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); return pathLine ? pathLine.replace("worktree ", "") : null; }) .filter(Boolean); for (const worktreePath of worktrees) { try { await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: repoPath, }); } catch { // Ignore errors } } // Remove the repository fs.rmSync(repoPath, { recursive: true, force: true }); } catch (error) { console.error("Failed to cleanup test repo:", error); } } /** * Cleanup a temp directory and all its contents */ export function cleanupTempDir(tempDir: string): void { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } // ============================================================================ // Git Operations // ============================================================================ /** * Execute a git command in a repository */ export async function gitExec( repoPath: string, command: string ): Promise<{ stdout: string; stderr: string }> { return execAsync(`git ${command}`, { cwd: repoPath }); } /** * Get list of git worktrees */ export async function listWorktrees(repoPath: string): Promise { try { const { stdout } = await execAsync("git worktree list --porcelain", { cwd: repoPath, }); return stdout .split("\n\n") .slice(1) // Skip main worktree .map((block) => { const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); return pathLine ? pathLine.replace("worktree ", "") : null; }) .filter(Boolean) as string[]; } catch { return []; } } /** * Get list of git branches */ export async function listBranches(repoPath: string): Promise { const { stdout } = await execAsync("git branch --list", { cwd: repoPath }); return stdout .split("\n") .map((line) => line.trim().replace(/^[*+]\s*/, "")) .filter(Boolean); } /** * Get the current branch name */ export async function getCurrentBranch(repoPath: string): Promise { const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath }); return stdout.trim(); } /** * Create a git branch */ export async function createBranch(repoPath: string, branchName: string): Promise { await execAsync(`git branch ${branchName}`, { cwd: repoPath }); } /** * Checkout a git branch */ export async function checkoutBranch(repoPath: string, branchName: string): Promise { await execAsync(`git checkout ${branchName}`, { cwd: repoPath }); } /** * Create a git worktree using git command directly */ export async function createWorktreeDirectly( repoPath: string, branchName: string, worktreePath?: string ): Promise { const sanitizedName = sanitizeBranchName(branchName); const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName); await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath }); return targetPath; } /** * Add and commit a file */ export async function commitFile( repoPath: string, filePath: string, content: string, message: string ): Promise { fs.writeFileSync(path.join(repoPath, filePath), content); await execAsync(`git add "${filePath}"`, { cwd: repoPath }); await execAsync(`git commit -m "${message}"`, { cwd: repoPath }); } /** * Get the latest commit message */ export async function getLatestCommitMessage(repoPath: string): Promise { const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath }); return stdout.trim(); } // ============================================================================ // Feature File Management // ============================================================================ /** * Create a feature file in the test repo */ export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void { const featuresDir = path.join(repoPath, ".automaker", "features"); const featureDir = path.join(featuresDir, featureId); fs.mkdirSync(featureDir, { recursive: true }); fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2)); } /** * Read a feature file from the test repo */ export function readTestFeature(repoPath: string, featureId: string): FeatureData | null { const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json"); if (!fs.existsSync(featureFilePath)) { return null; } return JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); } /** * List all feature directories in the test repo */ export function listTestFeatures(repoPath: string): string[] { const featuresDir = path.join(repoPath, ".automaker", "features"); if (!fs.existsSync(featuresDir)) { return []; } return fs.readdirSync(featuresDir); } // ============================================================================ // Project Setup for Tests // ============================================================================ /** * Set up localStorage with a project pointing to a test repo */ export async function setupProjectWithPath(page: Page, projectPath: string): Promise { await page.addInitScript((pathArg: string) => { const mockProject = { id: "test-project-worktree", name: "Worktree Test Project", path: pathArg, lastOpened: new Date().toISOString(), }; const mockState = { state: { projects: [mockProject], currentProject: mockProject, currentView: "board", theme: "dark", sidebarOpen: true, apiKeys: { anthropic: "", google: "" }, chatSessions: [], chatHistoryOpen: false, maxConcurrency: 3, aiProfiles: [], useWorktrees: true, // Enable worktree feature for tests currentWorktreeByProject: { [pathArg]: { path: null, branch: "main" }, // Initialize to main branch }, worktreesByProject: {}, }, version: 0, }; localStorage.setItem("automaker-storage", JSON.stringify(mockState)); // Mark setup as complete to skip the setup wizard const setupState = { state: { isFirstRun: false, setupComplete: true, currentStep: "complete", skipClaudeSetup: false, }, version: 0, }; localStorage.setItem("automaker-setup", JSON.stringify(setupState)); }, projectPath); } /** * Set up localStorage with a project pointing to a test repo with worktrees DISABLED * Use this to test scenarios where the worktree feature flag is off */ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise { await page.addInitScript((pathArg: string) => { const mockProject = { id: "test-project-no-worktree", name: "Test Project (No Worktrees)", path: pathArg, lastOpened: new Date().toISOString(), }; const mockState = { state: { projects: [mockProject], currentProject: mockProject, currentView: "board", theme: "dark", sidebarOpen: true, apiKeys: { anthropic: "", google: "" }, chatSessions: [], chatHistoryOpen: false, maxConcurrency: 3, aiProfiles: [], useWorktrees: false, // Worktree feature DISABLED currentWorktreeByProject: {}, worktreesByProject: {}, }, version: 0, }; localStorage.setItem("automaker-storage", JSON.stringify(mockState)); // Mark setup as complete to skip the setup wizard const setupState = { state: { isFirstRun: false, setupComplete: true, currentStep: "complete", skipClaudeSetup: false, }, version: 0, }; localStorage.setItem("automaker-setup", JSON.stringify(setupState)); }, projectPath); } /** * Set up localStorage with a project that has STALE worktree data * The currentWorktreeByProject points to a worktree path that no longer exists * This simulates the scenario where a user previously selected a worktree that was later deleted */ export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise { await page.addInitScript((pathArg: string) => { const mockProject = { id: "test-project-stale-worktree", name: "Stale Worktree Test Project", path: pathArg, lastOpened: new Date().toISOString(), }; const mockState = { state: { projects: [mockProject], currentProject: mockProject, currentView: "board", theme: "dark", sidebarOpen: true, apiKeys: { anthropic: "", google: "" }, chatSessions: [], chatHistoryOpen: false, maxConcurrency: 3, aiProfiles: [], useWorktrees: true, // Enable worktree feature for tests currentWorktreeByProject: { // This is STALE data - pointing to a worktree path that doesn't exist [pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" }, }, worktreesByProject: {}, }, version: 0, }; localStorage.setItem("automaker-storage", JSON.stringify(mockState)); // Mark setup as complete to skip the setup wizard const setupState = { state: { isFirstRun: false, setupComplete: true, currentStep: "complete", skipClaudeSetup: false, }, version: 0, }; localStorage.setItem("automaker-setup", JSON.stringify(setupState)); }, projectPath); } // ============================================================================ // Wait Utilities // ============================================================================ /** * Wait for the board view to load */ export async function waitForBoardView(page: Page): Promise { await page.waitForSelector('[data-testid="board-view"]', { timeout: TIMEOUTS.long }); } /** * Wait for the worktree selector to be visible */ export async function waitForWorktreeSelector(page: Page): Promise { await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => { // Fallback: wait for "Branch:" text return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium }); }); }