mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge origin/main into refactor/frontend
Resolved conflict in apps/ui/tests/worktree-integration.spec.ts: - Kept assertion verifying worktreePath is undefined (consistent with pattern) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,9 @@ const logger = createLogger("Worktree");
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
|
|
||||||
|
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
||||||
|
"chore: automaker initial commit";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize path separators to forward slashes for cross-platform consistency.
|
* Normalize path separators to forward slashes for cross-platform consistency.
|
||||||
* This ensures paths from `path.join()` (backslashes on Windows) match paths
|
* This ensures paths from `path.join()` (backslashes on Windows) match paths
|
||||||
@@ -77,3 +80,30 @@ export function logWorktreeError(
|
|||||||
// Re-export shared utilities
|
// Re-export shared utilities
|
||||||
export { getErrorMessageShared as getErrorMessage };
|
export { getErrorMessageShared as getErrorMessage };
|
||||||
export const logError = createLogError(logger);
|
export const logError = createLogError(logger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
|
||||||
|
* Returns true if an empty commit was created, false if the repo already had commits.
|
||||||
|
*/
|
||||||
|
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await execAsync(
|
||||||
|
`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`,
|
||||||
|
{ cwd: repoPath }
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const reason = getErrorMessageShared(error);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create initial git commit. Please commit manually and retry. ${reason}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import { exec } from "child_process";
|
|||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { mkdir } from "fs/promises";
|
import { mkdir } from "fs/promises";
|
||||||
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
|
import {
|
||||||
|
isGitRepo,
|
||||||
|
getErrorMessage,
|
||||||
|
logError,
|
||||||
|
normalizePath,
|
||||||
|
ensureInitialCommit,
|
||||||
|
} from "../common.js";
|
||||||
import { trackBranch } from "./branch-tracking.js";
|
import { trackBranch } from "./branch-tracking.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -93,6 +99,9 @@ export function createCreateHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
|
||||||
|
await ensureInitialCommit(projectPath);
|
||||||
|
|
||||||
// First, check if git already has a worktree for this branch (anywhere)
|
// First, check if git already has a worktree for this branch (anywhere)
|
||||||
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
||||||
if (existingWorktree) {
|
if (existingWorktree) {
|
||||||
|
|||||||
@@ -520,29 +520,28 @@ export class AutoModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Derive workDir from feature.branchName
|
// Derive workDir from feature.branchName
|
||||||
// If no branchName, derive from feature ID: feature/{featureId}
|
// Worktrees should already be created when the feature is added/edited
|
||||||
let worktreePath: string | null = null;
|
let worktreePath: string | null = null;
|
||||||
const branchName = feature.branchName || `feature/${featureId}`;
|
const branchName = feature.branchName;
|
||||||
|
|
||||||
if (useWorktrees && branchName) {
|
if (useWorktrees && branchName) {
|
||||||
// Try to find existing worktree for this branch
|
// Try to find existing worktree for this branch
|
||||||
|
// Worktree should already exist (created when feature was added/edited)
|
||||||
worktreePath = await this.findExistingWorktreeForBranch(
|
worktreePath = await this.findExistingWorktreeForBranch(
|
||||||
projectPath,
|
projectPath,
|
||||||
branchName
|
branchName
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (worktreePath) {
|
||||||
// Create worktree for this branch
|
console.log(
|
||||||
worktreePath = await this.setupWorktree(
|
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
|
||||||
projectPath,
|
);
|
||||||
featureId,
|
} else {
|
||||||
branchName
|
// Worktree doesn't exist - log warning and continue with project path
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Worktree for branch "${branchName}" not found, using project path`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure workDir is always an absolute path for cross-platform compatibility
|
// Ensure workDir is always an absolute path for cross-platform compatibility
|
||||||
@@ -552,7 +551,7 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Update running feature with actual worktree info
|
// Update running feature with actual worktree info
|
||||||
tempRunningFeature.worktreePath = worktreePath;
|
tempRunningFeature.worktreePath = worktreePath;
|
||||||
tempRunningFeature.branchName = branchName;
|
tempRunningFeature.branchName = branchName ?? null;
|
||||||
|
|
||||||
// Update feature status to in_progress
|
// Update feature status to in_progress
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||||
@@ -1479,60 +1478,6 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupWorktree(
|
|
||||||
projectPath: string,
|
|
||||||
featureId: string,
|
|
||||||
branchName: string
|
|
||||||
): Promise<string> {
|
|
||||||
// First, check if git already has a worktree for this branch (anywhere)
|
|
||||||
const existingWorktree = await this.findExistingWorktreeForBranch(
|
|
||||||
projectPath,
|
|
||||||
branchName
|
|
||||||
);
|
|
||||||
if (existingWorktree) {
|
|
||||||
// Path is already resolved to absolute in findExistingWorktreeForBranch
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
|
|
||||||
);
|
|
||||||
return existingWorktree;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Git worktrees stay in project directory
|
|
||||||
const worktreesDir = path.join(projectPath, ".worktrees");
|
|
||||||
const worktreePath = path.join(worktreesDir, featureId);
|
|
||||||
|
|
||||||
await fs.mkdir(worktreesDir, { recursive: true });
|
|
||||||
|
|
||||||
// Check if worktree directory already exists (might not be linked to branch)
|
|
||||||
try {
|
|
||||||
await fs.access(worktreePath);
|
|
||||||
// Return absolute path for cross-platform compatibility
|
|
||||||
return path.resolve(worktreePath);
|
|
||||||
} catch {
|
|
||||||
// Create new worktree
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create branch if it doesn't exist
|
|
||||||
try {
|
|
||||||
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
|
|
||||||
} catch {
|
|
||||||
// Branch may already exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create worktree
|
|
||||||
try {
|
|
||||||
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
|
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
// Return absolute path for cross-platform compatibility
|
|
||||||
return path.resolve(worktreePath);
|
|
||||||
} catch (error) {
|
|
||||||
// Worktree creation failed, fall back to direct execution
|
|
||||||
console.error(`[AutoMode] Worktree creation failed:`, error);
|
|
||||||
return path.resolve(projectPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadFeature(
|
private async loadFeature(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { createCreateHandler } from "@/routes/worktree/routes/create.js";
|
||||||
|
import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
describe("worktree create route - repositories without commits", () => {
|
||||||
|
let repoPath: string | null = null;
|
||||||
|
|
||||||
|
async function initRepoWithoutCommit() {
|
||||||
|
repoPath = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "automaker-no-commit-")
|
||||||
|
);
|
||||||
|
await execAsync("git init", { cwd: repoPath });
|
||||||
|
await execAsync('git config user.email "test@example.com"', {
|
||||||
|
cwd: repoPath,
|
||||||
|
});
|
||||||
|
await execAsync('git config user.name "Test User"', { cwd: repoPath });
|
||||||
|
// Intentionally skip creating an initial commit
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (!repoPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.rm(repoPath, { recursive: true, force: true });
|
||||||
|
repoPath = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an initial commit before adding a worktree when HEAD is missing", async () => {
|
||||||
|
await initRepoWithoutCommit();
|
||||||
|
const handler = createCreateHandler();
|
||||||
|
|
||||||
|
const json = vi.fn();
|
||||||
|
const status = vi.fn().mockReturnThis();
|
||||||
|
const req = {
|
||||||
|
body: { projectPath: repoPath, branchName: "feature/no-head" },
|
||||||
|
} as any;
|
||||||
|
const res = {
|
||||||
|
json,
|
||||||
|
status,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(status).not.toHaveBeenCalled();
|
||||||
|
expect(json).toHaveBeenCalled();
|
||||||
|
const payload = json.mock.calls[0][0];
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
|
||||||
|
const { stdout: commitCount } = await execAsync(
|
||||||
|
"git rev-list --count HEAD",
|
||||||
|
{ cwd: repoPath! }
|
||||||
|
);
|
||||||
|
expect(Number(commitCount.trim())).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const { stdout: latestMessage } = await execAsync(
|
||||||
|
"git log -1 --pretty=%B",
|
||||||
|
{ cwd: repoPath! }
|
||||||
|
);
|
||||||
|
expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
} from "../helpers/git-test-repo.js";
|
} from "../helpers/git-test-repo.js";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
vi.mock("@/providers/provider-factory.js");
|
vi.mock("@/providers/provider-factory.js");
|
||||||
|
|
||||||
@@ -43,13 +47,24 @@ describe("auto-mode-service.ts (integration)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("worktree operations", () => {
|
describe("worktree operations", () => {
|
||||||
it("should create git worktree for feature", async () => {
|
it("should use existing git worktree for feature", async () => {
|
||||||
// Create a test feature
|
const branchName = "feature/test-feature-1";
|
||||||
|
|
||||||
|
// Create a test feature with branchName set
|
||||||
await createTestFeature(testRepo.path, "test-feature-1", {
|
await createTestFeature(testRepo.path, "test-feature-1", {
|
||||||
id: "test-feature-1",
|
id: "test-feature-1",
|
||||||
category: "test",
|
category: "test",
|
||||||
description: "Test feature",
|
description: "Test feature",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
branchName: branchName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create worktree before executing (worktrees are now created when features are added/edited)
|
||||||
|
const worktreesDir = path.join(testRepo.path, ".worktrees");
|
||||||
|
const worktreePath = path.join(worktreesDir, "test-feature-1");
|
||||||
|
await fs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, {
|
||||||
|
cwd: testRepo.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock provider to complete quickly
|
// Mock provider to complete quickly
|
||||||
@@ -82,9 +97,20 @@ describe("auto-mode-service.ts (integration)", () => {
|
|||||||
false // isAutoMode
|
false // isAutoMode
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify branch was created
|
// Verify branch exists (was created when worktree was created)
|
||||||
const branches = await listBranches(testRepo.path);
|
const branches = await listBranches(testRepo.path);
|
||||||
expect(branches).toContain("feature/test-feature-1");
|
expect(branches).toContain(branchName);
|
||||||
|
|
||||||
|
// Verify worktree exists and is being used
|
||||||
|
// The service should have found and used the worktree (check via logs)
|
||||||
|
// We can verify the worktree exists by checking git worktree list
|
||||||
|
const worktrees = await listWorktrees(testRepo.path);
|
||||||
|
expect(worktrees.length).toBeGreaterThan(0);
|
||||||
|
// Verify that at least one worktree path contains our feature ID
|
||||||
|
const worktreePathsMatch = worktrees.some(wt =>
|
||||||
|
wt.includes("test-feature-1") || wt.includes(".worktrees")
|
||||||
|
);
|
||||||
|
expect(worktreePathsMatch).toBe(true);
|
||||||
|
|
||||||
// Note: Worktrees are not automatically cleaned up by the service
|
// Note: Worktrees are not automatically cleaned up by the service
|
||||||
// This is expected behavior - manual cleanup is required
|
// This is expected behavior - manual cleanup is required
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -115,8 +110,10 @@ export function SessionManager({
|
|||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
const [sessionToDelete, setSessionToDelete] =
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
useState<SessionListItem | null>(null);
|
||||||
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -233,11 +230,7 @@ export function SessionManager({
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!editingName.trim() || !api?.sessions) return;
|
if (!editingName.trim() || !api?.sessions) return;
|
||||||
|
|
||||||
const result = await api.sessions.update(
|
const result = await api.sessions.update(sessionId, editingName, undefined);
|
||||||
sessionId,
|
|
||||||
editingName,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface BranchAutocompleteProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -18,6 +19,7 @@ export function BranchAutocomplete({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
branches,
|
branches,
|
||||||
|
branchCardCounts,
|
||||||
placeholder = "Select a branch...",
|
placeholder = "Select a branch...",
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -27,12 +29,22 @@ export function BranchAutocomplete({
|
|||||||
// Always include "main" at the top of suggestions
|
// Always include "main" at the top of suggestions
|
||||||
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
||||||
const branchSet = new Set(["main", ...branches]);
|
const branchSet = new Set(["main", ...branches]);
|
||||||
return Array.from(branchSet).map((branch) => ({
|
return Array.from(branchSet).map((branch) => {
|
||||||
value: branch,
|
const cardCount = branchCardCounts?.[branch];
|
||||||
label: branch,
|
// Show card count if available, otherwise show "default" for main branch only
|
||||||
badge: branch === "main" ? "default" : undefined,
|
const badge = branchCardCounts !== undefined
|
||||||
}));
|
? String(cardCount ?? 0)
|
||||||
}, [branches]);
|
: branch === "main"
|
||||||
|
? "default"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: branch,
|
||||||
|
label: branch,
|
||||||
|
badge,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [branches, branchCardCounts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
|
|||||||
@@ -269,6 +269,17 @@ export function BoardView() {
|
|||||||
fetchBranches();
|
fetchBranches();
|
||||||
}, [currentProject, worktreeRefreshKey]);
|
}, [currentProject, worktreeRefreshKey]);
|
||||||
|
|
||||||
|
// Calculate unarchived card counts per branch
|
||||||
|
const branchCardCounts = useMemo(() => {
|
||||||
|
return hookFeatures.reduce((counts, feature) => {
|
||||||
|
if (feature.status !== "completed") {
|
||||||
|
const branch = feature.branchName ?? "main";
|
||||||
|
counts[branch] = (counts[branch] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
}, [hookFeatures]);
|
||||||
|
|
||||||
// Custom collision detection that prioritizes columns over cards
|
// Custom collision detection that prioritizes columns over cards
|
||||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||||
// First, check if pointer is within a column
|
// First, check if pointer is within a column
|
||||||
@@ -301,14 +312,14 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (matchesRemovedWorktree) {
|
if (matchesRemovedWorktree) {
|
||||||
// Reset the feature's branch assignment
|
// Reset the feature's branch assignment - update both local state and persist
|
||||||
persistFeatureUpdate(feature.id, {
|
const updates = { branchName: null as unknown as string | undefined };
|
||||||
branchName: null as unknown as string | undefined,
|
updateFeature(feature.id, updates);
|
||||||
});
|
persistFeatureUpdate(feature.id, updates);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[hookFeatures, persistFeatureUpdate]
|
[hookFeatures, updateFeature, persistFeatureUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||||
@@ -417,6 +428,18 @@ export function BoardView() {
|
|||||||
hookFeaturesRef.current = hookFeatures;
|
hookFeaturesRef.current = hookFeatures;
|
||||||
}, [hookFeatures]);
|
}, [hookFeatures]);
|
||||||
|
|
||||||
|
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
|
||||||
|
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||||
|
useEffect(() => {
|
||||||
|
runningAutoTasksRef.current = runningAutoTasks;
|
||||||
|
}, [runningAutoTasks]);
|
||||||
|
|
||||||
|
// Keep latest start handler without retriggering the auto mode effect
|
||||||
|
const handleStartImplementationRef = useRef(handleStartImplementation);
|
||||||
|
useEffect(() => {
|
||||||
|
handleStartImplementationRef.current = handleStartImplementation;
|
||||||
|
}, [handleStartImplementation]);
|
||||||
|
|
||||||
// Track features that are pending (started but not yet confirmed running)
|
// Track features that are pending (started but not yet confirmed running)
|
||||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -484,8 +507,9 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count currently running tasks + pending features
|
// Count currently running tasks + pending features
|
||||||
|
// Use ref to get the latest running tasks without causing effect re-runs
|
||||||
const currentRunning =
|
const currentRunning =
|
||||||
runningAutoTasks.length + pendingFeaturesRef.current.size;
|
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||||
const availableSlots = maxConcurrency - currentRunning;
|
const availableSlots = maxConcurrency - currentRunning;
|
||||||
|
|
||||||
// No available slots, skip check
|
// No available slots, skip check
|
||||||
@@ -540,6 +564,10 @@ export function BoardView() {
|
|||||||
|
|
||||||
// Start features up to available slots
|
// Start features up to available slots
|
||||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||||
|
const startImplementation = handleStartImplementationRef.current;
|
||||||
|
if (!startImplementation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const feature of featuresToStart) {
|
for (const feature of featuresToStart) {
|
||||||
// Check again before starting each feature
|
// Check again before starting each feature
|
||||||
@@ -565,7 +593,7 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start the implementation - server will derive workDir from feature.branchName
|
// Start the implementation - server will derive workDir from feature.branchName
|
||||||
const started = await handleStartImplementation(feature);
|
const started = await startImplementation(feature);
|
||||||
|
|
||||||
// If successfully started, track it as pending until we receive the start event
|
// If successfully started, track it as pending until we receive the start event
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -579,7 +607,7 @@ export function BoardView() {
|
|||||||
|
|
||||||
// Check immediately, then every 3 seconds
|
// Check immediately, then every 3 seconds
|
||||||
checkAndStartFeatures();
|
checkAndStartFeatures();
|
||||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
const interval = setInterval(checkAndStartFeatures, 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Mark as inactive to prevent any pending async operations from continuing
|
// Mark as inactive to prevent any pending async operations from continuing
|
||||||
@@ -591,7 +619,8 @@ export function BoardView() {
|
|||||||
}, [
|
}, [
|
||||||
autoMode.isRunning,
|
autoMode.isRunning,
|
||||||
currentProject,
|
currentProject,
|
||||||
runningAutoTasks,
|
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
|
||||||
|
// that would clear pendingFeaturesRef and cause concurrency issues
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
@@ -600,7 +629,6 @@ export function BoardView() {
|
|||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Use keyboard shortcuts hook (after actions hook)
|
// Use keyboard shortcuts hook (after actions hook)
|
||||||
@@ -639,7 +667,9 @@ export function BoardView() {
|
|||||||
// Find feature for pending plan approval
|
// Find feature for pending plan approval
|
||||||
const pendingApprovalFeature = useMemo(() => {
|
const pendingApprovalFeature = useMemo(() => {
|
||||||
if (!pendingPlanApproval) return null;
|
if (!pendingPlanApproval) return null;
|
||||||
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
|
return (
|
||||||
|
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
|
||||||
|
);
|
||||||
}, [pendingPlanApproval, hookFeatures]);
|
}, [pendingPlanApproval, hookFeatures]);
|
||||||
|
|
||||||
// Handle plan approval
|
// Handle plan approval
|
||||||
@@ -665,10 +695,10 @@ export function BoardView() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Immediately update local feature state to hide "Approve Plan" button
|
// Immediately update local feature state to hide "Approve Plan" button
|
||||||
// Get current feature to preserve version
|
// Get current feature to preserve version
|
||||||
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
||||||
updateFeature(featureId, {
|
updateFeature(featureId, {
|
||||||
planSpec: {
|
planSpec: {
|
||||||
status: 'approved',
|
status: "approved",
|
||||||
content: editedPlan || pendingPlanApproval.planContent,
|
content: editedPlan || pendingPlanApproval.planContent,
|
||||||
version: currentFeature?.planSpec?.version || 1,
|
version: currentFeature?.planSpec?.version || 1,
|
||||||
approvedAt: new Date().toISOString(),
|
approvedAt: new Date().toISOString(),
|
||||||
@@ -687,7 +717,14 @@ export function BoardView() {
|
|||||||
setPendingPlanApproval(null);
|
setPendingPlanApproval(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
[
|
||||||
|
pendingPlanApproval,
|
||||||
|
currentProject,
|
||||||
|
setPendingPlanApproval,
|
||||||
|
updateFeature,
|
||||||
|
loadFeatures,
|
||||||
|
hookFeatures,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle plan rejection
|
// Handle plan rejection
|
||||||
@@ -714,11 +751,11 @@ export function BoardView() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Immediately update local feature state
|
// Immediately update local feature state
|
||||||
// Get current feature to preserve version
|
// Get current feature to preserve version
|
||||||
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
||||||
updateFeature(featureId, {
|
updateFeature(featureId, {
|
||||||
status: 'backlog',
|
status: "backlog",
|
||||||
planSpec: {
|
planSpec: {
|
||||||
status: 'rejected',
|
status: "rejected",
|
||||||
content: pendingPlanApproval.planContent,
|
content: pendingPlanApproval.planContent,
|
||||||
version: currentFeature?.planSpec?.version || 1,
|
version: currentFeature?.planSpec?.version || 1,
|
||||||
reviewedByUser: true,
|
reviewedByUser: true,
|
||||||
@@ -736,7 +773,14 @@ export function BoardView() {
|
|||||||
setPendingPlanApproval(null);
|
setPendingPlanApproval(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
[
|
||||||
|
pendingPlanApproval,
|
||||||
|
currentProject,
|
||||||
|
setPendingPlanApproval,
|
||||||
|
updateFeature,
|
||||||
|
loadFeatures,
|
||||||
|
hookFeatures,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle opening approval dialog from feature card button
|
// Handle opening approval dialog from feature card button
|
||||||
@@ -747,7 +791,7 @@ export function BoardView() {
|
|||||||
// Determine the planning mode for approval (skip should never have a plan requiring approval)
|
// Determine the planning mode for approval (skip should never have a plan requiring approval)
|
||||||
const mode = feature.planningMode;
|
const mode = feature.planningMode;
|
||||||
const approvalMode: "lite" | "spec" | "full" =
|
const approvalMode: "lite" | "spec" | "full" =
|
||||||
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
|
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
|
||||||
|
|
||||||
// Re-open the approval dialog with the feature's plan data
|
// Re-open the approval dialog with the feature's plan data
|
||||||
setPendingPlanApproval({
|
setPendingPlanApproval({
|
||||||
@@ -832,6 +876,7 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
features={hookFeatures.map((f) => ({
|
features={hookFeatures.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
branchName: f.branchName,
|
branchName: f.branchName,
|
||||||
@@ -928,6 +973,7 @@ export function BoardView() {
|
|||||||
onAdd={handleAddFeature}
|
onAdd={handleAddFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
defaultBranch={selectedWorktreeBranch}
|
defaultBranch={selectedWorktreeBranch}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
@@ -943,6 +989,7 @@ export function BoardView() {
|
|||||||
onUpdate={handleUpdateFeature}
|
onUpdate={handleUpdateFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
@@ -1064,15 +1111,24 @@ export function BoardView() {
|
|||||||
onOpenChange={setShowDeleteWorktreeDialog}
|
onOpenChange={setShowDeleteWorktreeDialog}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
worktree={selectedWorktreeForAction}
|
worktree={selectedWorktreeForAction}
|
||||||
|
affectedFeatureCount={
|
||||||
|
selectedWorktreeForAction
|
||||||
|
? hookFeatures.filter(
|
||||||
|
(f) => f.branchName === selectedWorktreeForAction.branch
|
||||||
|
).length
|
||||||
|
: 0
|
||||||
|
}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
// Reset features that were assigned to the deleted worktree (by branch)
|
// Reset features that were assigned to the deleted worktree (by branch)
|
||||||
hookFeatures.forEach((feature) => {
|
hookFeatures.forEach((feature) => {
|
||||||
// Match by branch name since worktreePath is no longer stored
|
// Match by branch name since worktreePath is no longer stored
|
||||||
if (feature.branchName === deletedWorktree.branch) {
|
if (feature.branchName === deletedWorktree.branch) {
|
||||||
// Reset the feature's branch assignment
|
// Reset the feature's branch assignment - update both local state and persist
|
||||||
persistFeatureUpdate(feature.id, {
|
const updates = {
|
||||||
branchName: null as unknown as string | undefined,
|
branchName: null as unknown as string | undefined,
|
||||||
});
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ interface AddFeatureDialogProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
defaultBranch?: string;
|
defaultBranch?: string;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
@@ -87,6 +88,7 @@ export function AddFeatureDialog({
|
|||||||
onAdd,
|
onAdd,
|
||||||
categorySuggestions,
|
categorySuggestions,
|
||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
defaultBranch = "main",
|
defaultBranch = "main",
|
||||||
currentBranch,
|
currentBranch,
|
||||||
@@ -116,11 +118,16 @@ export function AddFeatureDialog({
|
|||||||
const [enhancementMode, setEnhancementMode] = useState<
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
"improve" | "technical" | "simplify" | "acceptance"
|
"improve" | "technical" | "simplify" | "acceptance"
|
||||||
>("improve");
|
>("improve");
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
|
|
||||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||||
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
const {
|
||||||
|
enhancementModel,
|
||||||
|
defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
useWorktrees,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
// Sync defaults when dialog opens
|
// Sync defaults when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,7 +141,13 @@ export function AddFeatureDialog({
|
|||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
}
|
}
|
||||||
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
}, [
|
||||||
|
open,
|
||||||
|
defaultSkipTests,
|
||||||
|
defaultBranch,
|
||||||
|
defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!newFeature.description.trim()) {
|
if (!newFeature.description.trim()) {
|
||||||
@@ -158,7 +171,7 @@ export function AddFeatureDialog({
|
|||||||
// If currentBranch is provided (non-primary worktree), use it
|
// If currentBranch is provided (non-primary worktree), use it
|
||||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||||
const finalBranchName = useCurrentBranch
|
const finalBranchName = useCurrentBranch
|
||||||
? (currentBranch || "")
|
? currentBranch || ""
|
||||||
: newFeature.branchName || "";
|
: newFeature.branchName || "";
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
@@ -399,6 +412,7 @@ export function AddFeatureDialog({
|
|||||||
setNewFeature({ ...newFeature, branchName: value })
|
setNewFeature({ ...newFeature, branchName: value })
|
||||||
}
|
}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
testIdPrefix="feature"
|
testIdPrefix="feature"
|
||||||
/>
|
/>
|
||||||
@@ -481,7 +495,10 @@ export function AddFeatureDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Options Tab */}
|
{/* Options Tab */}
|
||||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
<TabsContent
|
||||||
|
value="options"
|
||||||
|
className="space-y-4 overflow-y-auto cursor-default"
|
||||||
|
>
|
||||||
{/* Planning Mode Section */}
|
{/* Planning Mode Section */}
|
||||||
<PlanningModeSelector
|
<PlanningModeSelector
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
@@ -516,9 +533,7 @@ export function AddFeatureDialog({
|
|||||||
hotkeyActive={open}
|
hotkeyActive={open}
|
||||||
data-testid="confirm-add-feature"
|
data-testid="confirm-add-feature"
|
||||||
disabled={
|
disabled={
|
||||||
useWorktrees &&
|
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
|
||||||
!useCurrentBranch &&
|
|
||||||
!newFeature.branchName.trim()
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Add Feature
|
Add Feature
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
|
import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ interface DeleteWorktreeDialogProps {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
|
/** Number of features assigned to this worktree's branch */
|
||||||
|
affectedFeatureCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteWorktreeDialog({
|
export function DeleteWorktreeDialog({
|
||||||
@@ -37,6 +39,7 @@ export function DeleteWorktreeDialog({
|
|||||||
projectPath,
|
projectPath,
|
||||||
worktree,
|
worktree,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
|
affectedFeatureCount = 0,
|
||||||
}: DeleteWorktreeDialogProps) {
|
}: DeleteWorktreeDialogProps) {
|
||||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
const [deleteBranch, setDeleteBranch] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -99,6 +102,18 @@ export function DeleteWorktreeDialog({
|
|||||||
?
|
?
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{affectedFeatureCount > 0 && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
|
||||||
|
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-orange-500 text-sm">
|
||||||
|
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
|
||||||
|
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
|
||||||
|
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
|
||||||
|
unassigned and moved to the main worktree.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface EditFeatureDialogProps {
|
|||||||
) => void;
|
) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
@@ -89,6 +90,7 @@ export function EditFeatureDialog({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
categorySuggestions,
|
categorySuggestions,
|
||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -388,6 +390,7 @@ export function EditFeatureDialog({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
disabled={editingFeature.status !== "backlog"}
|
disabled={editingFeature.status !== "backlog"}
|
||||||
testIdPrefix="edit-feature"
|
testIdPrefix="edit-feature"
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ export function useBoardActions({
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
// Worktrees are created when adding/editing features with a branch name
|
||||||
// at execution time based on feature.branchName
|
// This ensures the worktree exists before the feature starts execution
|
||||||
|
|
||||||
const handleAddFeature = useCallback(
|
const handleAddFeature = useCallback(
|
||||||
async (featureData: {
|
async (featureData: {
|
||||||
@@ -100,24 +100,58 @@ export function useBoardActions({
|
|||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
// Simplified: Only store branchName, no worktree creation on add
|
|
||||||
// Worktrees are created at execution time (when feature starts)
|
|
||||||
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
||||||
// Non-empty string is the actual branch name (for non-primary worktrees)
|
// Non-empty string is the actual branch name (for non-primary worktrees)
|
||||||
const finalBranchName = featureData.branchName || undefined;
|
const finalBranchName = featureData.branchName || undefined;
|
||||||
|
|
||||||
|
// If worktrees enabled and a branch is specified, create the worktree now
|
||||||
|
// This ensures the worktree exists before the feature starts
|
||||||
|
if (useWorktrees && finalBranchName && currentProject) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.create) {
|
||||||
|
const result = await api.worktree.create(
|
||||||
|
currentProject.path,
|
||||||
|
finalBranchName
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||||
|
result.worktree?.isNew ? "created" : "already exists"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Refresh worktree list in UI
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error creating worktree:", error);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
status: "backlog" as const,
|
status: "backlog" as const,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
// No worktreePath - derived at runtime from branchName
|
|
||||||
};
|
};
|
||||||
const createdFeature = addFeature(newFeatureData);
|
const createdFeature = addFeature(newFeatureData);
|
||||||
// Must await to ensure feature exists on server before user can drag it
|
// Must await to ensure feature exists on server before user can drag it
|
||||||
await persistFeatureCreate(createdFeature);
|
await persistFeatureCreate(createdFeature);
|
||||||
saveCategory(featureData.category);
|
saveCategory(featureData.category);
|
||||||
},
|
},
|
||||||
[addFeature, persistFeatureCreate, saveCategory]
|
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateFeature = useCallback(
|
const handleUpdateFeature = useCallback(
|
||||||
@@ -139,6 +173,43 @@ export function useBoardActions({
|
|||||||
) => {
|
) => {
|
||||||
const finalBranchName = updates.branchName || undefined;
|
const finalBranchName = updates.branchName || undefined;
|
||||||
|
|
||||||
|
// If worktrees enabled and a branch is specified, create the worktree now
|
||||||
|
// This ensures the worktree exists before the feature starts
|
||||||
|
if (useWorktrees && finalBranchName && currentProject) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.create) {
|
||||||
|
const result = await api.worktree.create(
|
||||||
|
currentProject.path,
|
||||||
|
finalBranchName
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||||
|
result.worktree?.isNew ? "created" : "already exists"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Refresh worktree list in UI
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error creating worktree:", error);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const finalUpdates = {
|
const finalUpdates = {
|
||||||
...updates,
|
...updates,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
@@ -151,7 +222,7 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
setEditingFeature(null);
|
setEditingFeature(null);
|
||||||
},
|
},
|
||||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
|
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteFeature = useCallback(
|
const handleDeleteFeature = useCallback(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface BranchSelectorProps {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
onBranchNameChange: (branchName: string) => void;
|
onBranchNameChange: (branchName: string) => void;
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testIdPrefix?: string;
|
testIdPrefix?: string;
|
||||||
@@ -21,6 +22,7 @@ export function BranchSelector({
|
|||||||
branchName,
|
branchName,
|
||||||
onBranchNameChange,
|
onBranchNameChange,
|
||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
testIdPrefix = "branch",
|
testIdPrefix = "branch",
|
||||||
@@ -69,6 +71,7 @@ export function BranchSelector({
|
|||||||
value={branchName}
|
value={branchName}
|
||||||
onChange={onBranchNameChange}
|
onChange={onBranchNameChange}
|
||||||
branches={branchSuggestions}
|
branches={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
placeholder="Select or create branch..."
|
placeholder="Select or create branch..."
|
||||||
data-testid={`${testIdPrefix}-input`}
|
data-testid={`${testIdPrefix}-input`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
|||||||
|
|
||||||
interface WorktreeTabProps {
|
interface WorktreeTabProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
|
cardCount?: number; // Number of unarchived cards for this branch
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
@@ -43,6 +44,7 @@ interface WorktreeTabProps {
|
|||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
worktree,
|
worktree,
|
||||||
|
cardCount,
|
||||||
isSelected,
|
isSelected,
|
||||||
isRunning,
|
isRunning,
|
||||||
isActivating,
|
isActivating,
|
||||||
@@ -96,9 +98,9 @@ export function WorktreeTab({
|
|||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{worktree.hasChanges && (
|
{cardCount !== undefined && cardCount > 0 && (
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
{worktree.changedFilesCount}
|
{cardCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -139,9 +141,9 @@ export function WorktreeTab({
|
|||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{worktree.hasChanges && (
|
{cardCount !== undefined && cardCount > 0 && (
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
{worktree.changedFilesCount}
|
{cardCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ export interface WorktreePanelProps {
|
|||||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
runningFeatureIds?: string[];
|
runningFeatureIds?: string[];
|
||||||
features?: FeatureInfo[];
|
features?: FeatureInfo[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function WorktreePanel({
|
|||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
|
branchCardCounts,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
}: WorktreePanelProps) {
|
}: WorktreePanelProps) {
|
||||||
const {
|
const {
|
||||||
@@ -109,43 +110,47 @@ export function WorktreePanel({
|
|||||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{worktrees.map((worktree) => (
|
{worktrees.map((worktree) => {
|
||||||
<WorktreeTab
|
const cardCount = branchCardCounts?.[worktree.branch];
|
||||||
key={worktree.path}
|
return (
|
||||||
worktree={worktree}
|
<WorktreeTab
|
||||||
isSelected={isWorktreeSelected(worktree)}
|
key={worktree.path}
|
||||||
isRunning={hasRunningFeatures(worktree)}
|
worktree={worktree}
|
||||||
isActivating={isActivating}
|
cardCount={cardCount}
|
||||||
isDevServerRunning={isDevServerRunning(worktree)}
|
isSelected={isWorktreeSelected(worktree)}
|
||||||
devServerInfo={getDevServerInfo(worktree)}
|
isRunning={hasRunningFeatures(worktree)}
|
||||||
defaultEditorName={defaultEditorName}
|
isActivating={isActivating}
|
||||||
branches={branches}
|
isDevServerRunning={isDevServerRunning(worktree)}
|
||||||
filteredBranches={filteredBranches}
|
devServerInfo={getDevServerInfo(worktree)}
|
||||||
branchFilter={branchFilter}
|
defaultEditorName={defaultEditorName}
|
||||||
isLoadingBranches={isLoadingBranches}
|
branches={branches}
|
||||||
isSwitching={isSwitching}
|
filteredBranches={filteredBranches}
|
||||||
isPulling={isPulling}
|
branchFilter={branchFilter}
|
||||||
isPushing={isPushing}
|
isLoadingBranches={isLoadingBranches}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isSwitching={isSwitching}
|
||||||
aheadCount={aheadCount}
|
isPulling={isPulling}
|
||||||
behindCount={behindCount}
|
isPushing={isPushing}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
isStartingDevServer={isStartingDevServer}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
aheadCount={aheadCount}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
behindCount={behindCount}
|
||||||
onBranchFilterChange={setBranchFilter}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onSwitchBranch={handleSwitchBranch}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onCreateBranch={onCreateBranch}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
onPull={handlePull}
|
onBranchFilterChange={setBranchFilter}
|
||||||
onPush={handlePush}
|
onSwitchBranch={handleSwitchBranch}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onCreateBranch={onCreateBranch}
|
||||||
onCommit={onCommit}
|
onPull={handlePull}
|
||||||
onCreatePR={onCreatePR}
|
onPush={handlePush}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onStartDevServer={handleStartDevServer}
|
onCommit={onCommit}
|
||||||
onStopDevServer={handleStopDevServer}
|
onCreatePR={onCreatePR}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
/>
|
onStartDevServer={handleStartDevServer}
|
||||||
))}
|
onStopDevServer={handleStopDevServer}
|
||||||
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
expect(featureData.worktreePath).toBeUndefined();
|
expect(featureData.worktreePath).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should store branch name when adding feature with new branch (worktree created at execution)", async ({
|
test("should store branch name when adding feature with new branch (worktree created when adding feature)", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await setupProjectWithPath(page, testRepo.path);
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
@@ -788,7 +788,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
// Use a branch name that doesn't exist yet
|
// Use a branch name that doesn't exist yet
|
||||||
// Note: Worktrees are now created at execution time, not when adding to backlog
|
// Note: Worktrees are now created when features are added/edited, not at execution time
|
||||||
const branchName = "feature/auto-create-worktree";
|
const branchName = "feature/auto-create-worktree";
|
||||||
|
|
||||||
// Verify branch does NOT exist before we create the feature
|
// Verify branch does NOT exist before we create the feature
|
||||||
@@ -807,12 +807,16 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
// Confirm
|
// Confirm
|
||||||
await confirmAddFeature(page);
|
await confirmAddFeature(page);
|
||||||
|
|
||||||
// Wait for feature to be saved
|
// Wait for feature to be saved and worktree to be created
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Verify branch was NOT created when adding feature (created at execution time)
|
// Verify branch WAS created when adding feature (worktrees are created when features are added/edited)
|
||||||
const branchesAfter = await listBranches(testRepo.path);
|
const branchesAfter = await listBranches(testRepo.path);
|
||||||
expect(branchesAfter).not.toContain(branchName);
|
expect(branchesAfter).toContain(branchName);
|
||||||
|
|
||||||
|
// Verify worktree was created
|
||||||
|
const worktreePath = getWorktreePath(testRepo.path, branchName);
|
||||||
|
expect(fs.existsSync(worktreePath)).toBe(true);
|
||||||
|
|
||||||
// Verify feature was created with correct branch name stored
|
// Verify feature was created with correct branch name stored
|
||||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||||
@@ -2399,7 +2403,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
const newBranchName = "feature/edited-branch";
|
const newBranchName = "feature/edited-branch";
|
||||||
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
|
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
|
||||||
|
|
||||||
// Verify worktree does NOT exist before editing (worktrees are created at execution time)
|
// Verify worktree does NOT exist before editing
|
||||||
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
||||||
|
|
||||||
// Find and click the edit button on the feature card
|
// Find and click the edit button on the feature card
|
||||||
@@ -2435,22 +2439,19 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
|
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
|
|
||||||
// Wait for the dialog to close
|
// Wait for the dialog to close and worktree to be created
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Verify worktree was NOT created during editing (worktrees are created at execution time)
|
// Verify worktree WAS created during editing (worktrees are now created when features are added/edited)
|
||||||
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
expect(fs.existsSync(expectedWorktreePath)).toBe(true);
|
||||||
|
|
||||||
// Verify branch was NOT created (created at execution time)
|
// Verify branch WAS created (worktrees are created when features are added/edited)
|
||||||
const branches = await listBranches(testRepo.path);
|
const branches = await listBranches(testRepo.path);
|
||||||
expect(branches).not.toContain(newBranchName);
|
expect(branches).toContain(newBranchName);
|
||||||
|
|
||||||
// Verify feature was updated with correct branchName only
|
// Verify feature was updated with correct branchName
|
||||||
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
|
|
||||||
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
expect(featureData.branchName).toBe(newBranchName);
|
expect(featureData.branchName).toBe(newBranchName);
|
||||||
// worktreePath should not exist in the feature data
|
|
||||||
expect(featureData.worktreePath).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not create worktree when editing a feature and selecting main branch", async ({
|
test("should not create worktree when editing a feature and selecting main branch", async ({
|
||||||
|
|||||||
Reference in New Issue
Block a user