From 20cb030ec92a3c4243eaa7cb4ddfe24e7681ebed Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Sun, 18 Oct 2026 11:14:44 +0700 Subject: [PATCH] fix(opencode): skip plugin write on literal drift, glob token extraction, segment-anchored mutation check --- gitnexus/hooks/opencode/gitnexus-plugin.js | 24 +++++++++++++++------- gitnexus/src/cli/setup.ts | 3 ++- gitnexus/test/unit/opencode-plugin.test.ts | 20 ++++++++++++++---- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/gitnexus/hooks/opencode/gitnexus-plugin.js b/gitnexus/hooks/opencode/gitnexus-plugin.js index 9f943fdea..264dfe2dd 100644 --- a/gitnexus/hooks/opencode/gitnexus-plugin.js +++ b/gitnexus/hooks/opencode/gitnexus-plugin.js @@ -184,8 +184,13 @@ export function extractOpenCodePattern(tool, args) { } if (tool === 'glob') { const raw = typeof input.pattern === 'string' ? input.pattern : ''; - const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/); - return match ? match[1] : null; + // Longest literal run (>=3 chars) in the glob, with glob metachars as + // delimiters: '**/UserService*' → UserService, 'src/**/*.ts' → src, + // '**/*.service.ts' → service. The older [*/]letter regex missed every + // recursive glob whose segments start with '*', '.', or another metachar. + const runs = raw.match(/[a-zA-Z0-9_-]{3,}/g); + if (!runs) return null; + return runs.reduce((best, run) => (run.length > best.length ? run : best)); } if (tool === 'bash') { return extractBashSearchPattern(input.command); @@ -193,12 +198,17 @@ export function extractOpenCodePattern(tool, args) { return null; } -/** True for the git commands that move HEAD (same set as the Claude adapter). */ +/** + * True for the git commands that move HEAD (same set as the Claude adapter). + * Segments are checked after splitting on shell separators and each must + * START with `git`, so `echo 'git commit'` or `grep 'git commit' file` don't + * count as mutations while `cd x && git commit` still does. + */ export function isGitMutationCommand(command) { - return ( - typeof command === 'string' && - /\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command) - ); + if (typeof command !== 'string') return false; + return command + .split(/&&|\|\||;|\|/) + .some((seg) => /^\s*git\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(seg)); } /** diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 734e683b6..34c8829a7 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -1351,8 +1351,9 @@ async function installOpenCodePlugin(result: SetupResult): Promise { const jsonCli = JSON.stringify(normalizedCli); if (!content.includes(OPENCODE_CLI_PATH_SOURCE_LITERAL)) { result.errors.push( - 'OpenCode hooks: gitnexus-plugin.js no longer contains the cliPath literal to patch — the installed plugin may fail to resolve the CLI. Update OPENCODE_CLI_PATH_SOURCE_LITERAL in setup.ts.', + 'OpenCode hooks: gitnexus-plugin.js no longer contains the cliPath literal to patch — skipping plugin install (the unpatched relative cliPath would resolve under the OpenCode config dir). Update OPENCODE_CLI_PATH_SOURCE_LITERAL in setup.ts.', ); + return; } content = content.replace(OPENCODE_CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`); // Sanity: the installed copy must export the plugin initializer OpenCode calls. diff --git a/gitnexus/test/unit/opencode-plugin.test.ts b/gitnexus/test/unit/opencode-plugin.test.ts index b20971d2b..4422cd137 100644 --- a/gitnexus/test/unit/opencode-plugin.test.ts +++ b/gitnexus/test/unit/opencode-plugin.test.ts @@ -48,7 +48,9 @@ describe('extractOpenCodePattern', () => { it('derives a token from glob patterns', () => { expect(extractOpenCodePattern('glob', { pattern: '**/UserService*' })).toBe('UserService'); - expect(extractOpenCodePattern('glob', { pattern: 'src/**/*.ts' })).toBeNull(); + expect(extractOpenCodePattern('glob', { pattern: 'src/**/*.ts' })).toBe('src'); + expect(extractOpenCodePattern('glob', { pattern: '**/*.service.ts' })).toBe('service'); + expect(extractOpenCodePattern('glob', { pattern: '**/*.{a,b}' })).toBeNull(); expect(extractOpenCodePattern('glob', {})).toBeNull(); }); @@ -102,11 +104,21 @@ describe('isGitMutationCommand', () => { 'git rebase main', 'git cherry-pick abc', 'git pull', + 'cd src && git commit -m x', + 'npm test || git pull', ])('detects %s', (cmd) => expect(isGitMutationCommand(cmd)).toBe(true)); - it.each(['git status', 'git diff', 'git stash push', 'npm test', ''])('ignores %s', (cmd) => - expect(isGitMutationCommand(cmd)).toBe(false), - ); + it.each([ + 'git status', + 'git diff', + 'git stash push', + 'npm test', + '', + // Text that merely mentions a mutation must not count. + "echo 'git commit'", + "grep 'git commit' file.txt", + 'cat notes.md | grep git-pull', + ])('ignores %s', (cmd) => expect(isGitMutationCommand(cmd)).toBe(false)); it('rejects non-strings', () => { expect(isGitMutationCommand(null)).toBe(false);