test(worktree-isolation): add worktree detection tests

This commit is contained in:
Hibryda 2026-03-11 03:23:58 +01:00
parent 0da53e7390
commit 643ab0a6b6
8 changed files with 125 additions and 2 deletions

View file

@ -26,6 +26,8 @@ pub struct AgentQueryOptions {
pub model: Option<String>,
pub claude_config_dir: Option<String>,
pub additional_directories: Option<Vec<String>>,
/// When set, agent runs in a git worktree for isolation (passed as --worktree <name> CLI flag)
pub worktree_name: Option<String>,
/// Provider-specific configuration blob (passed through to sidecar as-is)
#[serde(default)]
pub provider_config: serde_json::Value,
@ -216,6 +218,7 @@ impl SidecarManager {
"model": options.model,
"claudeConfigDir": options.claude_config_dir,
"additionalDirectories": options.additional_directories,
"worktreeName": options.worktree_name,
"providerConfig": options.provider_config,
});

View file

@ -48,6 +48,7 @@ interface QueryMessage {
model?: string;
claudeConfigDir?: string;
additionalDirectories?: string[];
worktreeName?: string;
}
interface StopMessage {
@ -72,7 +73,7 @@ async function handleMessage(msg: Record<string, unknown>) {
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg;
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName } = msg;
if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' });
@ -126,6 +127,7 @@ async function handleQuery(msg: QueryMessage) {
: { type: 'preset' as const, preset: 'claude_code' as const },
model: model ?? undefined,
additionalDirectories: additionalDirectories ?? undefined,
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
},
});

View file

@ -20,6 +20,8 @@ export interface AgentQueryOptions {
model?: string;
claude_config_dir?: string;
additional_directories?: string[];
/** When set, agent runs in a git worktree for isolation */
worktree_name?: string;
provider_config?: Record<string, unknown>;
remote_machine_id?: string;
}

View file

@ -205,6 +205,7 @@ import {
isSidecarAlive,
setSidecarAlive,
waitForPendingPersistence,
detectWorktreeFromCwd,
} from './agent-dispatcher';
// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works
@ -622,4 +623,71 @@ describe('agent-dispatcher', () => {
await expect(waitForPendingPersistence()).resolves.toBeUndefined();
});
});
describe('detectWorktreeFromCwd', () => {
it('detects Claude Code worktree path', () => {
const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session');
expect(result).toBe('/.claude/worktrees/my-session');
});
it('detects Codex worktree path', () => {
const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1');
expect(result).toBe('/.codex/worktrees/task-1');
});
it('detects Cursor worktree path', () => {
const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x');
expect(result).toBe('/.cursor/worktrees/feature-x');
});
it('returns null for non-worktree CWD', () => {
expect(detectWorktreeFromCwd('/home/user/project')).toBeNull();
expect(detectWorktreeFromCwd('/tmp/work')).toBeNull();
});
it('returns null for empty string', () => {
expect(detectWorktreeFromCwd('')).toBeNull();
});
});
describe('init event CWD worktree detection', () => {
beforeEach(async () => {
await startAgentDispatcher();
});
it('calls setSessionWorktree when init CWD contains worktree path', async () => {
const { setSessionWorktree } = await import('./stores/conflicts.svelte');
// Override the mock adapter to return init with worktree CWD
const { adaptMessage } = await import('./adapters/message-adapters');
(adaptMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce([{
id: 'msg-wt',
type: 'init',
content: { sessionId: 'sdk-wt', model: 'claude-sonnet-4-20250514', cwd: '/home/user/repo/.claude/worktrees/my-session', tools: [] },
timestamp: Date.now(),
}]);
capturedCallbacks.msg!({
type: 'agent_event',
sessionId: 'sess-wt',
event: { type: 'system', subtype: 'init' },
});
expect(setSessionWorktree).toHaveBeenCalledWith('sess-wt', '/.claude/worktrees/my-session');
});
it('does not call setSessionWorktree for non-worktree CWD', async () => {
const { setSessionWorktree } = await import('./stores/conflicts.svelte');
(setSessionWorktree as ReturnType<typeof vi.fn>).mockClear();
capturedCallbacks.msg!({
type: 'agent_event',
sessionId: 'sess-normal',
event: { type: 'system', subtype: 'init' },
});
// The default mock returns cwd: '/tmp' which is not a worktree
expect(setSessionWorktree).not.toHaveBeenCalled();
});
});
});

View file

@ -182,6 +182,14 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
const init = msg.content as InitContent;
setAgentSdkSessionId(sessionId, init.sessionId);
setAgentModel(sessionId, init.model);
// CWD-based worktree detection: if init CWD contains a worktree path pattern,
// register it for conflict suppression (agents in different worktrees don't conflict)
if (init.cwd) {
const wtPath = detectWorktreeFromCwd(init.cwd);
if (wtPath) {
setSessionWorktree(sessionId, wtPath);
}
}
break;
}
@ -454,6 +462,22 @@ function triggerAutoAnchor(
notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`);
}
// Worktree path patterns for various providers
const WORKTREE_CWD_PATTERNS = [
/\/\.claude\/worktrees\/([^/]+)/, // Claude Code: <repo>/.claude/worktrees/<name>/
/\/\.codex\/worktrees\/([^/]+)/, // Codex
/\/\.cursor\/worktrees\/([^/]+)/, // Cursor
];
/** Extract worktree path from CWD if it matches a known worktree pattern */
export function detectWorktreeFromCwd(cwd: string): string | null {
for (const pattern of WORKTREE_CWD_PATTERNS) {
const match = cwd.match(pattern);
if (match) return match[0]; // Return the full worktree path segment
}
return null;
}
export function stopAgentDispatcher(): void {
if (unlistenMsg) {
unlistenMsg();

View file

@ -53,10 +53,11 @@
profile?: string;
provider?: ProviderId;
capabilities?: ProviderCapabilities;
useWorktrees?: boolean;
onExit?: () => void;
}
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props();
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, onExit }: Props = $props();
let session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt);
@ -184,6 +185,7 @@
setting_sources: ['user', 'project'],
claude_config_dir: profile?.config_dir,
system_prompt: systemPrompt,
worktree_name: useWorktrees ? sessionId : undefined,
});
inputPrompt = '';
if (promptRef) {

View file

@ -130,6 +130,7 @@
profile={project.profile || undefined}
provider={providerId}
capabilities={providerMeta?.capabilities}
useWorktrees={project.useWorktrees ?? false}
onExit={handleNewSession}
/>
{/if}

View file

@ -793,6 +793,21 @@
</div>
</div>
<div class="card-field card-field-row">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12"/><path d="M18 9a3 3 0 0 0-3-3H7"/><path d="M18 9v12"/></svg>
Worktree Isolation
</span>
<label class="card-toggle" title={project.useWorktrees ? 'Worktrees enabled' : 'Worktrees disabled'}>
<input
type="checkbox"
checked={project.useWorktrees ?? false}
onchange={e => updateProject(activeGroupId, project.id, { useWorktrees: (e.target as HTMLInputElement).checked })}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="card-footer">
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
@ -1200,6 +1215,12 @@
gap: 0.25rem;
}
.card-field.card-field-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.card-field-label {
display: flex;
align-items: center;