mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: enhance worktree management and feature filtering
- Added logic to show all local branches as suggestions in the branch autocomplete, allowing users to type new branch names. - Implemented current worktree information retrieval for filtering features based on the selected worktree's branch. - Updated feature handling to filter backlog features by the currently selected worktree branch, ensuring only relevant features are displayed. - Enhanced the WorktreeSelector component to utilize branch names for determining the appropriate worktree for features. - Introduced integration tests for worktree creation, deletion, and feature management to ensure robust functionality.
This commit is contained in:
@@ -215,6 +215,8 @@ export function BoardView() {
|
||||
}, [hookFeatures, persistedCategories]);
|
||||
|
||||
// Branch suggestions for the branch autocomplete
|
||||
// Shows all local branches as suggestions, but users can type any new branch name
|
||||
// When the feature is started, a worktree will be created if needed
|
||||
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
|
||||
|
||||
// Fetch branches when project changes or worktrees are created/modified
|
||||
@@ -283,6 +285,23 @@ export function BoardView() {
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Get current worktree info (path and branch) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() => (currentProject ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES),
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch = currentWorktreeBranch
|
||||
|| worktrees.find(w => w.isMain)?.branch
|
||||
|| "main";
|
||||
|
||||
// Extract all action handlers into a hook
|
||||
const {
|
||||
handleAddFeature,
|
||||
@@ -329,6 +348,7 @@ export function BoardView() {
|
||||
outputFeature,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
@@ -341,22 +361,6 @@ export function BoardView() {
|
||||
});
|
||||
|
||||
// Use drag and drop hook
|
||||
// Get current worktree info (path and branch) for filtering features
|
||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() => (currentProject ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES),
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch = currentWorktreeBranch
|
||||
|| worktrees.find(w => w.isMain)?.branch
|
||||
|| "main";
|
||||
|
||||
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
|
||||
features: hookFeatures,
|
||||
currentProject,
|
||||
@@ -448,7 +452,7 @@ export function BoardView() {
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath }))}
|
||||
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath, branchName: f.branchName }))}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
|
||||
@@ -62,6 +62,7 @@ interface DevServerInfo {
|
||||
interface FeatureInfo {
|
||||
id: string;
|
||||
worktreePath?: string;
|
||||
branchName?: string; // Used as fallback to determine which worktree the spinner should show on
|
||||
}
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
@@ -302,14 +303,25 @@ export function WorktreeSelector({
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
// For main worktree, check features with no worktreePath or matching projectPath
|
||||
// First, check if worktreePath is set and matches
|
||||
// Use pathsEqual for cross-platform compatibility (Windows uses backslashes)
|
||||
if (worktree.isMain) {
|
||||
return !feature.worktreePath || pathsEqual(feature.worktreePath, projectPath);
|
||||
if (feature.worktreePath) {
|
||||
if (worktree.isMain) {
|
||||
// Feature has worktreePath - show on main only if it matches projectPath
|
||||
return pathsEqual(feature.worktreePath, projectPath);
|
||||
}
|
||||
// For non-main worktrees, check if worktreePath matches
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
}
|
||||
|
||||
// For other worktrees, check if worktreePath matches
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
// If worktreePath is not set, use branchName as fallback
|
||||
if (feature.branchName) {
|
||||
// Feature has a branchName - show spinner on the worktree with matching branch
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
// No worktreePath and no branchName - default to main
|
||||
return worktree.isMain;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface UseBoardActionsProps {
|
||||
outputFeature: Feature | null;
|
||||
projectPath: string | null;
|
||||
onWorktreeCreated?: () => void;
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
||||
}
|
||||
|
||||
export function useBoardActions({
|
||||
@@ -65,6 +66,7 @@ export function useBoardActions({
|
||||
outputFeature,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const {
|
||||
addFeature,
|
||||
@@ -720,7 +722,24 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
const backlogFeatures = features.filter((f) => f.status === "backlog");
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This ensures "G" only starts features from the filtered list
|
||||
const backlogFeatures = features.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
|
||||
// Determine the feature's branch (default to "main" if not set)
|
||||
const featureBranch = f.branchName || "main";
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
||||
// show features with no branch or "main"/"master" branch
|
||||
if (!currentWorktreeBranch || currentWorktreeBranch === "main" || currentWorktreeBranch === "master") {
|
||||
return !f.branchName || featureBranch === "main" || featureBranch === "master";
|
||||
}
|
||||
|
||||
// Otherwise, only show features matching the selected worktree branch
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const availableSlots =
|
||||
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||
|
||||
@@ -734,7 +753,9 @@ export function useBoardActions({
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
toast.info("Backlog empty", {
|
||||
description: "No features in backlog to start.",
|
||||
description: currentWorktreeBranch && currentWorktreeBranch !== "main" && currentWorktreeBranch !== "master"
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -764,6 +785,7 @@ export function useBoardActions({
|
||||
getOrCreateWorktreeForFeature,
|
||||
persistFeatureUpdate,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
]);
|
||||
|
||||
const handleDeleteAllVerified = useCallback(async () => {
|
||||
|
||||
631
apps/app/tests/worktree-integration.spec.ts
Normal file
631
apps/app/tests/worktree-integration.spec.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Get workspace root for test fixture path
|
||||
function getWorkspaceRoot(): string {
|
||||
const cwd = process.cwd();
|
||||
if (cwd.includes("apps/app")) {
|
||||
return path.resolve(cwd, "../..");
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
const WORKSPACE_ROOT = getWorkspaceRoot();
|
||||
// Use a unique temp dir based on process ID and random string to avoid collisions
|
||||
const UNIQUE_ID = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const TEST_TEMP_DIR = path.join(WORKSPACE_ROOT, "test", `temp-worktree-tests-${UNIQUE_ID}`);
|
||||
|
||||
interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary git repository for testing
|
||||
*/
|
||||
async function createTestGitRepo(): Promise<TestRepo> {
|
||||
// Create temp directory if it doesn't exist
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpDir = path.join(TEST_TEMP_DIR, `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 });
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
// Remove all worktrees first
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: tmpDir,
|
||||
}).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: tmpDir,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the repository
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup test repo:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a feature file in the test repo
|
||||
*/
|
||||
function createTestFeature(
|
||||
repoPath: string,
|
||||
featureId: string,
|
||||
featureData: {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: string;
|
||||
branchName?: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
): 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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git worktrees
|
||||
*/
|
||||
async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
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
|
||||
*/
|
||||
async function listBranches(repoPath: string): Promise<string[]> {
|
||||
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to our test repo
|
||||
*/
|
||||
async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
|
||||
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: [],
|
||||
},
|
||||
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 for network to be idle
|
||||
*/
|
||||
async function waitForNetworkIdle(page: Page): Promise<void> {
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the board view to load
|
||||
*/
|
||||
async function waitForBoardView(page: Page): Promise<void> {
|
||||
await page.waitForSelector('[data-testid="board-view"]', { timeout: 30000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the add feature button
|
||||
*/
|
||||
async function clickAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="add-feature-button"]');
|
||||
await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the add feature dialog
|
||||
*/
|
||||
async function fillAddFeatureDialog(
|
||||
page: Page,
|
||||
description: string,
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
// Fill description (using the dropzone textarea)
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill(description);
|
||||
|
||||
// Fill branch if provided (it's a combobox autocomplete)
|
||||
if (options?.branch) {
|
||||
const branchButton = page.locator('[data-testid="feature-branch-input"]');
|
||||
await branchButton.click();
|
||||
// Wait for the popover to open
|
||||
await page.waitForTimeout(300);
|
||||
// Type in the command input
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
await commandInput.fill(options.branch);
|
||||
// Press Enter to select/create the branch
|
||||
await commandInput.press("Enter");
|
||||
// Wait for popover to close
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Fill category if provided (it's also a combobox autocomplete)
|
||||
if (options?.category) {
|
||||
const categoryButton = page.locator('[data-testid="feature-category-input"]');
|
||||
await categoryButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
await commandInput.fill(options.category);
|
||||
await commandInput.press("Enter");
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the add feature dialog
|
||||
*/
|
||||
async function confirmAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="confirm-add-feature"]');
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Configure all tests to run serially to prevent interference
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite: Worktree Integration Tests
|
||||
// ============================================================================
|
||||
test.describe("Worktree Integration Tests", () => {
|
||||
let testRepo: TestRepo;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Create a fresh test repo for each test
|
||||
testRepo = await createTestGitRepo();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Cleanup test repo after each test
|
||||
if (testRepo) {
|
||||
await testRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
if (fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.rmSync(TEST_TEMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should display worktree selector with main branch", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Verify the worktree selector is visible - look for the "Branch:" label
|
||||
const branchLabel = page.getByText("Branch:");
|
||||
await expect(branchLabel).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify main branch button is displayed
|
||||
const mainBranchButton = page.getByRole("button", { name: "main" });
|
||||
await expect(mainBranchButton).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should create a worktree via API and verify filesystem", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Create worktree via API directly (simulating the dialog action)
|
||||
const branchName = "feature/test-worktree";
|
||||
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const expectedWorktreePath = path.join(testRepo.path, ".worktrees", sanitizedName);
|
||||
|
||||
// Make the API call directly through the server
|
||||
const response = await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: branchName,
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const result = await response.json();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify worktree was created on filesystem
|
||||
const worktreeExists = fs.existsSync(expectedWorktreePath);
|
||||
expect(worktreeExists).toBe(true);
|
||||
|
||||
// Verify branch was created
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain(branchName);
|
||||
|
||||
// Verify worktree is listed by git
|
||||
const worktrees = await listWorktrees(testRepo.path);
|
||||
expect(worktrees.length).toBe(1);
|
||||
expect(worktrees[0]).toBe(expectedWorktreePath);
|
||||
});
|
||||
|
||||
test("should create two worktrees and list them both", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Create first worktree
|
||||
const response1 = await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: "feature/worktree-one",
|
||||
},
|
||||
});
|
||||
expect(response1.ok()).toBe(true);
|
||||
|
||||
// Create second worktree
|
||||
const response2 = await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: "feature/worktree-two",
|
||||
},
|
||||
});
|
||||
expect(response2.ok()).toBe(true);
|
||||
|
||||
// Verify both worktrees exist on filesystem
|
||||
const worktrees = await listWorktrees(testRepo.path);
|
||||
expect(worktrees.length).toBe(2);
|
||||
|
||||
// Verify branches were created
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain("feature/worktree-one");
|
||||
expect(branches).toContain("feature/worktree-two");
|
||||
});
|
||||
|
||||
test("should delete a worktree via API and verify cleanup", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// First create a worktree
|
||||
const branchName = "feature/to-delete";
|
||||
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const worktreePath = path.join(testRepo.path, ".worktrees", sanitizedName);
|
||||
|
||||
const createResponse = await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: branchName,
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
|
||||
// Verify it was created
|
||||
expect(fs.existsSync(worktreePath)).toBe(true);
|
||||
|
||||
// Now delete it
|
||||
const deleteResponse = await page.request.post("http://localhost:3008/api/worktree/delete", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
worktreePath: worktreePath,
|
||||
deleteBranch: true,
|
||||
},
|
||||
});
|
||||
expect(deleteResponse.ok()).toBe(true);
|
||||
|
||||
// Verify worktree directory is removed
|
||||
expect(fs.existsSync(worktreePath)).toBe(false);
|
||||
|
||||
// Verify branch is deleted
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).not.toContain(branchName);
|
||||
});
|
||||
|
||||
test("should add a feature to backlog with specific branch", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Create a worktree first
|
||||
const branchName = "feature/test-branch";
|
||||
await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: branchName,
|
||||
},
|
||||
});
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
await fillAddFeatureDialog(page, "Test feature for worktree", {
|
||||
branch: branchName,
|
||||
category: "Testing",
|
||||
});
|
||||
|
||||
// Confirm
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify feature was created with correct branch by checking the filesystem
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
expect(featureDirs.length).toBeGreaterThan(0);
|
||||
|
||||
// Find and read the feature file
|
||||
const featureDir = featureDirs[0];
|
||||
const featureFilePath = path.join(featuresDir, featureDir, "feature.json");
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
|
||||
expect(featureData.description).toBe("Test feature for worktree");
|
||||
expect(featureData.branchName).toBe(branchName);
|
||||
expect(featureData.status).toBe("backlog");
|
||||
});
|
||||
|
||||
test("should filter features by selected worktree", async ({ page }) => {
|
||||
// Create the worktrees first
|
||||
await execAsync(`git worktree add ".worktrees/feature-worktree-a" -b feature/worktree-a`, {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
await execAsync(`git worktree add ".worktrees/feature-worktree-b" -b feature/worktree-b`, {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// First click on main to ensure we're on the main branch
|
||||
const mainButton = page.getByRole("button", { name: "main" }).first();
|
||||
await mainButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Create feature for main branch - don't specify branch, use the default (main)
|
||||
await clickAddFeature(page);
|
||||
// Just fill description without specifying branch - it should default to main
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill("Feature for main branch");
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for feature to be created and visible in backlog
|
||||
const mainFeatureText = page.getByText("Feature for main branch");
|
||||
await expect(mainFeatureText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Switch to worktree-a and create a feature there
|
||||
const worktreeAButton = page.getByRole("button", { name: /feature\/worktree-a/i });
|
||||
await worktreeAButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Main feature should not be visible now
|
||||
await expect(mainFeatureText).not.toBeVisible();
|
||||
|
||||
// Create feature for worktree-a - don't specify branch, use the default
|
||||
await clickAddFeature(page);
|
||||
const descriptionInput2 = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput2.fill("Feature for worktree A");
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for feature to be visible
|
||||
const worktreeAText = page.getByText("Feature for worktree A");
|
||||
await expect(worktreeAText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Switch to worktree-b and create a feature
|
||||
const worktreeBButton = page.getByRole("button", { name: /feature\/worktree-b/i });
|
||||
await worktreeBButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// worktree-a feature should not be visible
|
||||
await expect(worktreeAText).not.toBeVisible();
|
||||
|
||||
await clickAddFeature(page);
|
||||
const descriptionInput3 = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput3.fill("Feature for worktree B");
|
||||
await confirmAddFeature(page);
|
||||
|
||||
const worktreeBText = page.getByText("Feature for worktree B");
|
||||
await expect(worktreeBText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Switch back to main and verify filtering
|
||||
await mainButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(mainFeatureText).toBeVisible({ timeout: 10000 });
|
||||
await expect(worktreeAText).not.toBeVisible();
|
||||
await expect(worktreeBText).not.toBeVisible();
|
||||
|
||||
// Switch to worktree-a and verify
|
||||
await worktreeAButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(worktreeAText).toBeVisible({ timeout: 10000 });
|
||||
await expect(mainFeatureText).not.toBeVisible();
|
||||
await expect(worktreeBText).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should pre-fill branch when creating feature from selected worktree", async ({ page }) => {
|
||||
// Create a worktree first
|
||||
const branchName = "feature/pre-fill-test";
|
||||
await execAsync(`git worktree add ".worktrees/feature-pre-fill-test" -b ${branchName}`, {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for worktree selector to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click on the worktree to select it
|
||||
const worktreeButton = page.getByRole("button", { name: /feature\/pre-fill-test/i });
|
||||
await worktreeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open add feature dialog
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Verify the branch input button shows the selected worktree's branch
|
||||
// The branch input is a combobox button, so check its text content
|
||||
const branchButton = page.locator('[data-testid="feature-branch-input"]');
|
||||
await expect(branchButton).toContainText(branchName, { timeout: 5000 });
|
||||
|
||||
// Close dialog
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("should list worktrees via API", async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Create some worktrees first
|
||||
await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: "feature/list-test-1",
|
||||
},
|
||||
});
|
||||
await page.request.post("http://localhost:3008/api/worktree/create", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
branchName: "feature/list-test-2",
|
||||
},
|
||||
});
|
||||
|
||||
// List worktrees via API
|
||||
const listResponse = await page.request.post("http://localhost:3008/api/worktree/list", {
|
||||
data: {
|
||||
projectPath: testRepo.path,
|
||||
includeDetails: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(listResponse.ok()).toBe(true);
|
||||
const result = await listResponse.json();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.worktrees).toHaveLength(3); // main + 2 worktrees
|
||||
|
||||
// Verify worktree details
|
||||
const branches = result.worktrees.map((w: { branch: string }) => w.branch);
|
||||
expect(branches).toContain("main");
|
||||
expect(branches).toContain("feature/list-test-1");
|
||||
expect(branches).toContain("feature/list-test-2");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user