Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,85 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
}
});

// Create a new git worktree for a project and register it
app.post('/api/projects/:projectName/worktrees', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { branchName } = req.body;

if (!branchName || !branchName.trim()) {
return res.status(400).json({ error: 'branchName is required' });
}

// Validate branch name — only allow safe git branch characters
const safeBranchPattern = /^[a-zA-Z0-9._/\-]+$/;
if (!safeBranchPattern.test(branchName.trim())) {
return res.status(400).json({ error: 'Invalid branch name' });
}

const branch = branchName.trim();
const mainRepoPath = await extractProjectDirectory(projectName);

if (!mainRepoPath) {
return res.status(404).json({ error: 'Project not found' });
}

// Determine worktree path: sibling directory named <repo>-<branch>
// e.g. /home/user/myrepo + feature/foo → /home/user/myrepo-feature-foo
const repoName = path.basename(mainRepoPath);
const safeSuffix = branch.replace(/[/\\]/g, '-');
const worktreePath = path.join(path.dirname(mainRepoPath), `${repoName}-${safeSuffix}`);

// Validate the resolved path is inside the allowed workspace boundary
const workspaceValidation = await validateWorkspacePath(worktreePath);
if (!workspaceValidation.isValid) {
return res.status(400).json({ error: 'Worktree path is outside the allowed workspace area' });
}
Comment on lines +628 to +631
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use valid, not isValid, from validateWorkspacePath().

Line 629 checks a field this helper does not return. As written, workspaceValidation.isValid is undefined, so this branch always returns 400 and the endpoint never creates a worktree.

🐛 Minimal fix
-        if (!workspaceValidation.isValid) {
+        if (!workspaceValidation.valid) {
             return res.status(400).json({ error: 'Worktree path is outside the allowed workspace area' });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const workspaceValidation = await validateWorkspacePath(worktreePath);
if (!workspaceValidation.isValid) {
return res.status(400).json({ error: 'Worktree path is outside the allowed workspace area' });
}
const workspaceValidation = await validateWorkspacePath(worktreePath);
if (!workspaceValidation.valid) {
return res.status(400).json({ error: 'Worktree path is outside the allowed workspace area' });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/index.js` around lines 628 - 631, The handler incorrectly checks
workspaceValidation.isValid (which is undefined) after calling
validateWorkspacePath(); change the condition to use the correct property
workspaceValidation.valid (i.e., if (!workspaceValidation.valid) return
res.status(400).json(...)) so the endpoint only rejects when the helper
indicates the path is invalid; update any related variable uses of isValid to
valid in this function (e.g., the worktree creation branch) to ensure the
endpoint can proceed when validation passes.


// Check if the worktree path already exists
try {
await fsPromises.access(worktreePath);
return res.status(409).json({ error: `Path already exists: ${worktreePath}` });
} catch {
// Good — path does not exist
}

// Determine whether the branch already exists in the repo
let branchExists = false;
try {
await new Promise((resolve, reject) => {
const check = spawn('git', ['rev-parse', '--verify', branch], { cwd: mainRepoPath });
check.on('close', code => code === 0 ? resolve() : reject());
});
branchExists = true;
} catch {
branchExists = false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const worktreeArgs = branchExists
? ['worktree', 'add', worktreePath, branch]
: ['worktree', 'add', '-b', branch, worktreePath];

await new Promise((resolve, reject) => {
const proc = spawn('git', worktreeArgs, { cwd: mainRepoPath });
let stderr = '';
proc.stderr.on('data', d => { stderr += d.toString(); });
proc.on('close', code => {
if (code === 0) resolve();
else reject(new Error(stderr.trim() || `git worktree add exited with code ${code}`));
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Register the new worktree as a project so it appears in the sidebar
const project = await addProjectManually(worktreePath);

res.json({ success: true, worktreePath, project });
} catch (error) {
console.error('Error creating worktree:', error);
res.status(500).json({ error: 'Failed to create worktree' });
}
});

// Search conversations content (SSE streaming)
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
Expand Down
74 changes: 74 additions & 0 deletions server/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
import { execFile } from 'child_process';
import { promisify } from 'util';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';

const execFileAsync = promisify(execFile);

// Import TaskMaster detection functions
async function detectTaskMasterFolder(projectPath) {
try {
Expand Down Expand Up @@ -381,6 +385,52 @@ async function extractProjectDirectory(projectName) {
}
}

/**
* Detect if a given path is a git linked worktree and return metadata.
* Uses `git worktree list --porcelain` which lists all worktrees from any
* worktree path (the first entry is always the main worktree).
*
* Returns null if not in a git repo or if the git command fails.
* Returns { isWorktree: false } for the main worktree.
* Returns { isWorktree: true, mainRepoPath, worktreeBranch } for linked worktrees.
*/
async function resolveWorktreeInfo(projectPath) {
try {
const { stdout } = await execFileAsync('git', ['-C', projectPath, 'worktree', 'list', '--porcelain']);
// Each worktree block is separated by a blank line
const blocks = stdout.trim().split(/\n\n+/);
const worktrees = blocks.map(block => {
const result = {};
for (const line of block.trim().split('\n')) {
if (line.startsWith('worktree ')) result.path = line.slice(9).trim();
else if (line.startsWith('branch ')) result.branch = line.slice(7).trim().replace('refs/heads/', '');
else if (line === 'detached') result.branch = 'HEAD (detached)';
}
return result;
}).filter(w => w.path);

if (worktrees.length === 0) return null;

const mainWorktree = worktrees[0];
const resolvedPath = path.resolve(projectPath);

if (path.resolve(mainWorktree.path) === resolvedPath) {
return { isWorktree: false };
}

const linked = worktrees.find(w => path.resolve(w.path) === resolvedPath);
if (!linked) return null;

return {
isWorktree: true,
mainRepoPath: path.resolve(mainWorktree.path),
worktreeBranch: linked.branch || 'HEAD (detached)',
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return null;
}
}

async function getProjects(progressCallback = null) {
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
Expand Down Expand Up @@ -513,6 +563,18 @@ async function getProjects(progressCallback = null) {
};
}

// Detect git worktree membership
try {
const worktreeInfo = await resolveWorktreeInfo(actualProjectDir);
if (worktreeInfo) {
project.isWorktree = worktreeInfo.isWorktree;
project.mainRepoPath = worktreeInfo.mainRepoPath || null;
project.worktreeBranch = worktreeInfo.worktreeBranch || null;
}
} catch (e) {
// Non-fatal: worktree detection failure should not break project listing
}

projects.push(project);
}
} catch (error) {
Expand Down Expand Up @@ -625,6 +687,18 @@ async function getProjects(progressCallback = null) {
};
}

// Detect git worktree membership for manual projects
try {
const worktreeInfo = await resolveWorktreeInfo(actualProjectDir);
if (worktreeInfo) {
project.isWorktree = worktreeInfo.isWorktree;
project.mainRepoPath = worktreeInfo.mainRepoPath || null;
project.worktreeBranch = worktreeInfo.worktreeBranch || null;
}
} catch (e) {
// Non-fatal
}

projects.push(project);
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/app/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function AppContent() {
} = useSessionProtection();

const {
projects,
selectedProject,
selectedSession,
activeTab,
Expand All @@ -39,6 +40,7 @@ export default function AppContent() {
setShowSettings,
openSettings,
refreshProjectsSilently,
handleProjectSelect,
sidebarSharedProps,
} = useProjectsState({
sessionId,
Expand Down Expand Up @@ -159,8 +161,10 @@ export default function AppContent() {

<div className="flex min-w-0 flex-1 flex-col">
<MainContent
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
activeTab={activeTab}
setActiveTab={setActiveTab}
ws={ws}
Expand Down
2 changes: 2 additions & 0 deletions src/components/chat/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ export interface Question {
}

export interface ChatInterfaceProps {
projects?: Project[];
selectedProject: Project | null;
selectedSession: ProjectSession | null;
onProjectSelect?: (project: Project) => void;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: any;
Expand Down
4 changes: 4 additions & 0 deletions src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ type PendingViewSession = {
};

function ChatInterface({
projects = [],
selectedProject,
selectedSession,
onProjectSelect,
ws,
sendMessage,
latestMessage,
Expand Down Expand Up @@ -317,6 +319,8 @@ function ChatInterface({
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
linkedWorktrees={projects.filter(p => p.isWorktree && p.mainRepoPath === selectedProject.fullPath)}
onWorktreeSelect={onProjectSelect}
isLoadingMoreMessages={isLoadingMoreMessages}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
Expand Down
6 changes: 6 additions & 0 deletions src/components/chat/view/subcomponents/ChatMessagesPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ interface ChatMessagesPaneProps {
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: Dispatch<SetStateAction<string>>;
linkedWorktrees?: Project[];
onWorktreeSelect?: (project: Project) => void;
isLoadingMoreMessages: boolean;
hasMoreMessages: boolean;
totalMessages: number;
Expand Down Expand Up @@ -75,6 +77,8 @@ export default function ChatMessagesPane({
isTaskMasterInstalled,
onShowAllTasks,
setInput,
linkedWorktrees,
onWorktreeSelect,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
Expand Down Expand Up @@ -158,6 +162,8 @@ export default function ChatMessagesPane({
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
linkedWorktrees={linkedWorktrees}
onWorktreeSelect={onWorktreeSelect}
/>
) : (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Check, ChevronDown } from "lucide-react";
import { Check, ChevronDown, GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
Expand All @@ -8,7 +8,7 @@ import {
CODEX_MODELS,
GEMINI_MODELS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, SessionProvider } from "../../../../types/app";
import type { Project, ProjectSession, SessionProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";

type ProviderSelectionEmptyStateProps = {
Expand All @@ -29,6 +29,8 @@ type ProviderSelectionEmptyStateProps = {
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
linkedWorktrees?: Project[];
onWorktreeSelect?: (project: Project) => void;
};

type ProviderDef = {
Expand Down Expand Up @@ -113,6 +115,8 @@ export default function ProviderSelectionEmptyState({
isTaskMasterInstalled,
onShowAllTasks,
setInput,
linkedWorktrees = [],
onWorktreeSelect,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
Expand Down Expand Up @@ -155,6 +159,26 @@ export default function ProviderSelectionEmptyState({
return (
<div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Linked-worktree workspace switcher */}
{linkedWorktrees.length > 0 && onWorktreeSelect && (
<div className="mb-6 flex flex-wrap items-center justify-center gap-2">
<span className="flex items-center gap-1 text-[12px] text-muted-foreground">
<GitBranch className="h-3 w-3" />
Switch workspace:
</span>
{linkedWorktrees.map((wt) => (
<button
key={wt.name}
onClick={() => onWorktreeSelect(wt)}
className="flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-medium text-emerald-700 transition-colors hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40"
>
<GitBranch className="h-2.5 w-2.5" />
{wt.worktreeBranch ?? wt.displayName}
</button>
))}
</div>
)}

{/* Heading */}
<div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
Expand Down
2 changes: 2 additions & 0 deletions src/components/main-content/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type PrdFile = {
};

export type MainContentProps = {
projects: Project[];
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeTab: AppTab;
Expand All @@ -52,6 +53,7 @@ export type MainContentProps = {
onReplaceTemporarySession: SessionLifecycleHandler;
onNavigateToSession: (targetSessionId: string) => void;
onShowSettings: () => void;
onProjectSelect: (project: Project) => void;
externalMessageUpdate: number;
};

Expand Down
4 changes: 4 additions & 0 deletions src/components/main-content/view/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type TasksSettingsContextValue = {
};

function MainContent({
projects,
selectedProject,
selectedSession,
activeTab,
Expand All @@ -47,6 +48,7 @@ function MainContent({
onReplaceTemporarySession,
onNavigateToSession,
onShowSettings,
onProjectSelect,
externalMessageUpdate,
}: MainContentProps) {
const { preferences } = useUiPreferences();
Expand Down Expand Up @@ -112,6 +114,7 @@ function MainContent({
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails>
<ChatInterface
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
Expand All @@ -127,6 +130,7 @@ function MainContent({
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
onProjectSelect={onProjectSelect}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
Expand Down
Loading