feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression

This commit is contained in:
Hibryda 2026-03-11 00:25:26 +01:00
parent 05191127ea
commit 38b8447ae6
8 changed files with 354 additions and 24 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractFilePaths, extractWritePaths } from './tool-files';
import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files';
import type { ToolCallContent } from '../adapters/sdk-messages';
function makeTc(name: string, input: unknown): ToolCallContent {
@ -32,7 +32,7 @@ describe('extractFilePaths', () => {
expect(result).toEqual([{ path: '/src', op: 'grep' }]);
});
it('extracts Bash file paths from common commands', () => {
it('extracts Bash read paths from common commands', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' }));
expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]);
});
@ -51,10 +51,57 @@ describe('extractFilePaths', () => {
const result = extractFilePaths(makeTc('Read', {}));
expect(result).toEqual([]);
});
// Bash write detection
it('detects echo > redirect as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "hello" > /tmp/out.txt' }));
expect(result).toEqual([{ path: '/tmp/out.txt', op: 'write' }]);
});
it('detects >> append redirect as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "data" >> /tmp/log.txt' }));
expect(result).toEqual([{ path: '/tmp/log.txt', op: 'write' }]);
});
it('detects sed -i as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: "sed -i 's/foo/bar/g' /src/config.ts" }));
expect(result).toEqual([{ path: '/src/config.ts', op: 'write' }]);
});
it('detects tee as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "content" | tee /tmp/output.log' }));
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
});
it('detects tee -a as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "append" | tee -a /tmp/output.log' }));
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
});
it('detects cp destination as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'cp /src/a.ts /src/b.ts' }));
expect(result).toEqual([{ path: '/src/b.ts', op: 'write' }]);
});
it('detects mv destination as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'mv /old/file.ts /new/file.ts' }));
expect(result).toEqual([{ path: '/new/file.ts', op: 'write' }]);
});
it('ignores /dev/null redirects', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "test" > /dev/null' }));
expect(result).toEqual([]);
});
it('prefers write detection over read for ambiguous commands', () => {
// "cat file > out" should detect the write target, not the read source
const result = extractFilePaths(makeTc('Bash', { command: 'cat /src/input.ts > /tmp/output.ts' }));
expect(result).toEqual([{ path: '/tmp/output.ts', op: 'write' }]);
});
});
describe('extractWritePaths', () => {
it('returns only write-op paths', () => {
it('returns only write-op paths for Write/Edit tools', () => {
expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']);
expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']);
});
@ -65,7 +112,37 @@ describe('extractWritePaths', () => {
expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]);
});
it('returns empty for bash commands', () => {
it('returns empty for bash read commands', () => {
expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]);
});
it('detects bash write commands', () => {
expect(extractWritePaths(makeTc('Bash', { command: 'echo "x" > /tmp/out.ts' }))).toEqual(['/tmp/out.ts']);
expect(extractWritePaths(makeTc('Bash', { command: "sed -i 's/a/b/' /src/file.ts" }))).toEqual(['/src/file.ts']);
expect(extractWritePaths(makeTc('Bash', { command: 'cp /a.ts /b.ts' }))).toEqual(['/b.ts']);
});
});
describe('extractWorktreePath', () => {
it('detects Agent tool with isolation: worktree', () => {
const result = extractWorktreePath(makeTc('Agent', { prompt: 'do stuff', isolation: 'worktree' }));
expect(result).toMatch(/^worktree:/);
});
it('detects Task tool with isolation: worktree', () => {
const result = extractWorktreePath(makeTc('Task', { prompt: 'do stuff', isolation: 'worktree' }));
expect(result).toMatch(/^worktree:/);
});
it('returns null for Agent without isolation', () => {
expect(extractWorktreePath(makeTc('Agent', { prompt: 'do stuff' }))).toBeNull();
});
it('detects EnterWorktree with path', () => {
expect(extractWorktreePath(makeTc('EnterWorktree', { path: '/tmp/wt-1' }))).toBe('/tmp/wt-1');
});
it('returns null for unrelated tool', () => {
expect(extractWorktreePath(makeTc('Read', { file_path: '/foo' }))).toBeNull();
});
});