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 model: Option<String>,
|
||||||
pub claude_config_dir: Option<String>,
|
pub claude_config_dir: Option<String>,
|
||||||
pub additional_directories: Option<Vec<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)
|
/// Provider-specific configuration blob (passed through to sidecar as-is)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider_config: serde_json::Value,
|
pub provider_config: serde_json::Value,
|
||||||
|
|
@ -216,6 +218,7 @@ impl SidecarManager {
|
||||||
"model": options.model,
|
"model": options.model,
|
||||||
"claudeConfigDir": options.claude_config_dir,
|
"claudeConfigDir": options.claude_config_dir,
|
||||||
"additionalDirectories": options.additional_directories,
|
"additionalDirectories": options.additional_directories,
|
||||||
|
"worktreeName": options.worktree_name,
|
||||||
"providerConfig": options.provider_config,
|
"providerConfig": options.provider_config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ interface QueryMessage {
|
||||||
model?: string;
|
model?: string;
|
||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
|
worktreeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StopMessage {
|
interface StopMessage {
|
||||||
|
|
@ -72,7 +73,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuery(msg: QueryMessage) {
|
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)) {
|
if (sessions.has(sessionId)) {
|
||||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
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 },
|
: { type: 'preset' as const, preset: 'claude_code' as const },
|
||||||
model: model ?? undefined,
|
model: model ?? undefined,
|
||||||
additionalDirectories: additionalDirectories ?? undefined,
|
additionalDirectories: additionalDirectories ?? undefined,
|
||||||
|
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export interface AgentQueryOptions {
|
||||||
model?: string;
|
model?: string;
|
||||||
claude_config_dir?: string;
|
claude_config_dir?: string;
|
||||||
additional_directories?: string[];
|
additional_directories?: string[];
|
||||||
|
/** When set, agent runs in a git worktree for isolation */
|
||||||
|
worktree_name?: string;
|
||||||
provider_config?: Record<string, unknown>;
|
provider_config?: Record<string, unknown>;
|
||||||
remote_machine_id?: string;
|
remote_machine_id?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,7 @@ import {
|
||||||
isSidecarAlive,
|
isSidecarAlive,
|
||||||
setSidecarAlive,
|
setSidecarAlive,
|
||||||
waitForPendingPersistence,
|
waitForPendingPersistence,
|
||||||
|
detectWorktreeFromCwd,
|
||||||
} from './agent-dispatcher';
|
} from './agent-dispatcher';
|
||||||
|
|
||||||
// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works
|
// 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();
|
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;
|
const init = msg.content as InitContent;
|
||||||
setAgentSdkSessionId(sessionId, init.sessionId);
|
setAgentSdkSessionId(sessionId, init.sessionId);
|
||||||
setAgentModel(sessionId, init.model);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,6 +462,22 @@ function triggerAutoAnchor(
|
||||||
notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`);
|
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 {
|
export function stopAgentDispatcher(): void {
|
||||||
if (unlistenMsg) {
|
if (unlistenMsg) {
|
||||||
unlistenMsg();
|
unlistenMsg();
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,11 @@
|
||||||
profile?: string;
|
profile?: string;
|
||||||
provider?: ProviderId;
|
provider?: ProviderId;
|
||||||
capabilities?: ProviderCapabilities;
|
capabilities?: ProviderCapabilities;
|
||||||
|
useWorktrees?: boolean;
|
||||||
onExit?: () => void;
|
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 session = $derived(getAgentSession(sessionId));
|
||||||
let inputPrompt = $state(initialPrompt);
|
let inputPrompt = $state(initialPrompt);
|
||||||
|
|
@ -184,6 +185,7 @@
|
||||||
setting_sources: ['user', 'project'],
|
setting_sources: ['user', 'project'],
|
||||||
claude_config_dir: profile?.config_dir,
|
claude_config_dir: profile?.config_dir,
|
||||||
system_prompt: systemPrompt,
|
system_prompt: systemPrompt,
|
||||||
|
worktree_name: useWorktrees ? sessionId : undefined,
|
||||||
});
|
});
|
||||||
inputPrompt = '';
|
inputPrompt = '';
|
||||||
if (promptRef) {
|
if (promptRef) {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@
|
||||||
profile={project.profile || undefined}
|
profile={project.profile || undefined}
|
||||||
provider={providerId}
|
provider={providerId}
|
||||||
capabilities={providerMeta?.capabilities}
|
capabilities={providerMeta?.capabilities}
|
||||||
|
useWorktrees={project.useWorktrees ?? false}
|
||||||
onExit={handleNewSession}
|
onExit={handleNewSession}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -793,6 +793,21 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card-footer">
|
||||||
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
|
<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>
|
<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;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-field.card-field-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.card-field-label {
|
.card-field-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue