From 06b94a41974e14fdb6c58f7186e861149ec69192 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 15 Oct 2026 08:15:35 -0400 Subject: [PATCH] fix: handle git diff and message send failures gracefully Treat Git diff as optional context when Git fails, and report asynchronous message send errors without blocking input auto-hide. Add coverage for non-repository and rejected-send cases. This should fix #531 --- lua/opencode/commands/handlers/workflow.lua | 4 +- lua/opencode/context/base_context.lua | 12 ++-- tests/unit/api_spec.lua | 2 +- tests/unit/base_context_git_diff_spec.lua | 65 +++++++++++++++++++ .../unit/commands_handlers_workflow_spec.lua | 18 ++++- 5 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 tests/unit/base_context_git_diff_spec.lua diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 282c6ab2..3aa50d40 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -362,7 +362,9 @@ M.actions.submit_input_prompt = Promise.async(function() return end - require('opencode.services.messaging').send_message(input_content) + require('opencode.services.messaging').send_message(input_content):catch(function(err) + require('opencode.log').notify('Error sending message: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) if config.ui.input.auto_hide and not input_window.is_hidden() then input_window._hide() end diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua index 773aab9f..5aae12f1 100644 --- a/lua/opencode/context/base_context.lua +++ b/lua/opencode/context/base_context.lua @@ -263,12 +263,14 @@ M.get_git_diff = Promise.async(function(context_config) return nil end - return Promise.system({ 'git', 'diff', '--cached', '--minimal' }):and_then(function(output) - if output == '' then - return nil - end - return output.stdout + -- Optional context must not prevent sending from non-repositories or after Git failures. + local ok, output = pcall(function() + return Promise.system({ 'git', 'diff', '--cached', '--minimal' }):await() end) + if not ok or output.stdout == '' then + return nil + end + return output.stdout end) ---@param file table diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 4854fab4..ed0bfeb1 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -382,7 +382,7 @@ describe('opencode.api', function() local after_run_stub = stub(messaging, 'after_run') local send_message_stub = stub(messaging, 'send_message').invokes(function(prompt) require('opencode.services.messaging').after_run(prompt) - return true + return resolved(true) end) local take_input_stub = stub(input_window, 'take_input').returns('hello') local is_hidden_stub = stub(input_window, 'is_hidden').returns(true) diff --git a/tests/unit/base_context_git_diff_spec.lua b/tests/unit/base_context_git_diff_spec.lua new file mode 100644 index 00000000..7af5534f --- /dev/null +++ b/tests/unit/base_context_git_diff_spec.lua @@ -0,0 +1,65 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local base_context = require('opencode.context.base_context') +local Promise = require('opencode.promise') + +describe('Git diff context', function() + local system + local enabled = { git_diff = { enabled = true } } + + before_each(function() + system = stub(Promise, 'system') + end) + + after_each(function() + system:revert() + end) + + it('does not run Git when disabled', function() + assert.is_nil(base_context.get_git_diff({ git_diff = { enabled = false } }):wait()) + assert.stub(system).was_not_called() + end) + + it('returns staged changes', function() + system.returns(Promise.new():resolve({ stdout = 'staged diff' })) + assert.equals('staged diff', base_context.get_git_diff(enabled):wait()) + assert.stub(system).was_called_with({ 'git', 'diff', '--cached', '--minimal' }) + end) + + it('returns nil for an empty diff', function() + system.returns(Promise.new():resolve({ stdout = '' })) + assert.is_nil(base_context.get_git_diff(enabled):wait()) + end) + + it('returns nil when Git rejects', function() + system.returns(Promise.new():reject({ code = 129, stderr = 'not a git repository' })) + assert.is_nil(base_context.get_git_diff(enabled):wait()) + end) + + it('returns nil when Git cannot start', function() + system.invokes(function() + error('Git executable not found') + end) + assert.is_nil(base_context.get_git_diff(enabled):wait()) + end) +end) + +describe('Git diff context outside a repository', function() + local cwd, directory + + before_each(function() + cwd = vim.fn.getcwd() + directory = '/tmp/opencode/git-diff-' .. vim.uv.hrtime() + vim.fn.mkdir(directory, 'p') + vim.fn.chdir(directory) + end) + + after_each(function() + vim.fn.chdir(cwd) + vim.fn.delete(directory, 'd') + end) + + it('resolves nil instead of rejecting', function() + assert.is_nil(base_context.get_git_diff({ git_diff = { enabled = true } }):wait()) + end) +end) diff --git a/tests/unit/commands_handlers_workflow_spec.lua b/tests/unit/commands_handlers_workflow_spec.lua index e4b11119..99276891 100644 --- a/tests/unit/commands_handlers_workflow_spec.lua +++ b/tests/unit/commands_handlers_workflow_spec.lua @@ -32,7 +32,7 @@ describe('opencode.commands.handlers.workflow', function() state.store.set_raw('windows', { input_buf = buf, input_win = vim.api.nvim_get_current_win() }) state.store.set_raw('display_route', nil) config.ui.input.auto_hide = true - send_message = stub(require('opencode.services.messaging'), 'send_message').returns(false) + send_message = stub(require('opencode.services.messaging'), 'send_message').returns(Promise.new():resolve(false)) hide = stub(input_window, '_hide') hidden = stub(input_window, 'is_hidden').returns(false) get_key = stub(config, 'get_key_for_function').returns('/') @@ -76,6 +76,22 @@ describe('opencode.commands.handlers.workflow', function() assert.stub(hide).was_called(1) end) + it('reports asynchronous send failures without waiting to auto-hide', function() + local pending = Promise.new() + send_message.returns(pending) + submit({ 'hello' }) + assert.stub(hide).was_called(1) + assert.stub(notify).was_not_called() + + pending:reject('context preparation failed') + assert.is_true(vim.wait(1000, function() + return #notify.calls > 0 + end)) + assert + .stub(notify) + .was_called_with('[opencode.nvim] Error sending message: ' .. vim.inspect('context preparation failed'), vim.log.levels.ERROR) + end) + it('clears empty input without sending or hiding', function() submit({ '' }) assert.stub(send_message).was_not_called()