test(worktree-isolation): add worktree detection tests
This commit is contained in:
parent
0da53e7390
commit
643ab0a6b6
8 changed files with 125 additions and 2 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@
|
|||
profile={project.profile || undefined}
|
||||
provider={providerId}
|
||||
capabilities={providerMeta?.capabilities}
|
||||
useWorktrees={project.useWorktrees ?? false}
|
||||
onExit={handleNewSession}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue