diff --git a/gitnexus/hooks/opencode/gitnexus-plugin.js b/gitnexus/hooks/opencode/gitnexus-plugin.js index 51791f72a..baa9aa7cd 100644 --- a/gitnexus/hooks/opencode/gitnexus-plugin.js +++ b/gitnexus/hooks/opencode/gitnexus-plugin.js @@ -79,9 +79,16 @@ export function extractAugmentContext(stderr) { return output.slice(marker).trim(); } +/** + * rg/grep flags whose following argv token IS the search pattern + * (`rg -e foo file.ts`, `grep --regexp foo .`). They must not be lumped in + * with value-consuming flags — skipping the value would augment on the + * filename instead of the search. + */ +const PATTERN_FLAGS = new Set(['-e', '--regexp', '--expression']); + /** Flags of rg/grep that consume the following argv token as a value. */ const FLAGS_WITH_VALUES = new Set([ - '-e', '-f', '-m', '-A', @@ -103,18 +110,25 @@ export function extractBashSearchPattern(command) { if (typeof command !== 'string' || !/\brg\b|\bgrep\b/.test(command)) return null; const tokens = command.split(/\s+/); let foundCmd = false; - let skipNext = false; + // 'skip' = drop the next token (flag value); 'pattern' = take it as the search. + let pending = null; for (const token of tokens) { - if (skipNext) { - skipNext = false; + if (pending === 'skip') { + pending = null; continue; } + if (pending === 'pattern') { + pending = null; + const cleaned = token.replace(/['"]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } if (!foundCmd) { if (/\brg$|\bgrep$/.test(token)) foundCmd = true; continue; } if (token.startsWith('-')) { - if (FLAGS_WITH_VALUES.has(token)) skipNext = true; + if (PATTERN_FLAGS.has(token)) pending = 'pattern'; + else if (FLAGS_WITH_VALUES.has(token)) pending = 'skip'; continue; } const cleaned = token.replace(/['"]/g, ''); diff --git a/gitnexus/src/cli/uninstall.ts b/gitnexus/src/cli/uninstall.ts index 9e99b105e..1b076cbe7 100644 --- a/gitnexus/src/cli/uninstall.ts +++ b/gitnexus/src/cli/uninstall.ts @@ -205,8 +205,11 @@ async function removeFile(filePath: string, dryRun: boolean): Promise { try { const stat = await fs.stat(filePath); if (!stat.isFile()) return false; - } catch { - return false; + } catch (err) { + // Only "not there" is a clean no-op; anything else (EACCES, EIO, ...) + // must surface — the caller records it in result.errors. + if (isEnoent(err)) return false; + throw err; } if (!dryRun) await fs.rm(filePath, { force: true }); return true; diff --git a/gitnexus/test/unit/opencode-plugin.test.ts b/gitnexus/test/unit/opencode-plugin.test.ts index b6ece5dba..31b3d380a 100644 --- a/gitnexus/test/unit/opencode-plugin.test.ts +++ b/gitnexus/test/unit/opencode-plugin.test.ts @@ -72,6 +72,14 @@ describe('extractBashSearchPattern', () => { expect(extractBashSearchPattern('grep -r UserService .')).toBe('UserService'); }); + it('treats -e/--regexp/--expression as the pattern itself, not a value to skip', () => { + expect(extractBashSearchPattern('rg -e UserService .')).toBe('UserService'); + expect(extractBashSearchPattern('grep -e UserService file.ts')).toBe('UserService'); + expect(extractBashSearchPattern('rg --regexp UserService .')).toBe('UserService'); + // The pattern flag must still win over a following positional filename. + expect(extractBashSearchPattern('rg -n -e UserService setup.ts')).toBe('UserService'); + }); + it('rejects short tokens and non-search commands', () => { expect(extractBashSearchPattern('rg -n "ab" .')).toBeNull(); expect(extractBashSearchPattern('npm test')).toBeNull(); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index 709a16fc3..f88d171ff 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -444,6 +444,36 @@ describe('uninstallCommand', () => { expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus'); }); + it('reports a non-ENOENT stat error on the plugin file instead of silently skipping', async () => { + const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js'); + await fs.mkdir(path.dirname(pluginFile), { recursive: true }); + await fs.writeFile(pluginFile, '// gitnexus', 'utf-8'); + + // Simulate EACCES on the plugin stat only; every other stat passes through. + const realStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, 'stat').mockImplementation((async (p: any, ...rest: any[]) => { + if (String(p).endsWith(path.join('plugins', 'gitnexus.js'))) { + const err: any = new Error(`EACCES: permission denied, stat '${p}'`); + err.code = 'EACCES'; + throw err; + } + return (realStat as any)(p, ...rest); + }) as any); + + const logSpy = vi.spyOn(console, 'log'); + const uninstallCommand = await importUninstall(); + await uninstallCommand({ force: true }); + + // Before the fix, removeFile swallowed every stat error → plugin left in + // place with no error reported. Now EACCES surfaces in the Errors block. + const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('hooks plugin'); + expect(statSpy).toHaveBeenCalled(); + // The plugin file was never reached (rm), so it still exists. + expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus'); + expect(process.exitCode).toBe(1); + }); + // ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ── it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => { const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');