From de91c6429bc0358b69832db2e1fb9264a6b82a5f Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:26:20 +0200 Subject: [PATCH 1/2] fix(core): eliminate silent failures, WARN on every degraded outcome Every swallowed failure now leaves a WARN trace (debug_mode-independent, issue #5 contract) and the sidebar reports the truth: - git#add_submodule: check the pointer commit, fail the add on error - remove: check deinit + commit_removal; op label reflects commit failure; fix double vim_dir prefix on absolute module paths - update: warn on unreachable pin target; stash push failure no longer pretends a stash exists (phantom pop); auto-commit failure downgrades the op and footer - vimrc/helptags/check/async/add/declare/backup/util: warn on the previously invisible failure branches tests/log_warnings.vader: 15 WARN regression tests with debug_mode unset. Closes #7 --- CHANGELOG.md | 33 +++ autoload/plugin_manager/async.vim | 5 +- autoload/plugin_manager/cmd/add.vim | 3 + autoload/plugin_manager/cmd/backup.vim | 13 +- autoload/plugin_manager/cmd/check.vim | 5 +- autoload/plugin_manager/cmd/declare.vim | 4 + autoload/plugin_manager/cmd/helptags.vim | 10 + autoload/plugin_manager/cmd/remove.vim | 54 +++- autoload/plugin_manager/cmd/update.vim | 61 ++++- autoload/plugin_manager/core/util.vim | 4 + autoload/plugin_manager/git.vim | 16 +- autoload/plugin_manager/vimrc.vim | 4 +- tests/log_warnings.vader | 321 +++++++++++++++++++++++ 13 files changed, 502 insertions(+), 31 deletions(-) create mode 100644 tests/log_warnings.vader diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef357c..c42dbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to the Vim Plugin Manager will be documented in this file. +## [Unreleased] + +### Fixed +- **No silent failures left** (#7): every degraded outcome writes a WARN + line (always logged, `debug_mode`-independent) and sidebar labels tell + the truth: + - `git#add_submodule`: the pointer commit result is checked; a failure + warns and fails the add instead of returning success. + - `remove`: `git submodule deinit` and `commit_removal` results are + checked; a failed pointer commit downgrades the op to + "Removed (pointer commit failed)" instead of a plain "Removed". + - `remove`: a module path that is already absolute is no longer + prefixed with `vim_dir` twice (filesystem-search removals). + - `update`: unreachable pin target warns, not sidebar-only. + - `update`: `stash_if_needed` returns 0 when `git stash push` fails + (previously returned 1, causing a phantom `stash pop`). + - `update`: auto-commit failures downgrade the op to + "Updated (auto-commit failed)" and add a truthful footer line. + - `vimrc`: unparsable declarations log at WARN instead of the + debug-gated level. + - `helptags`: a real `:helptags` failure is reported as + "Helptags failed" + WARN, no longer as the "skip / No doc directory". + - `check`: empty catch around `on_done` now warns. + - `async`: callback errors write a WARN in addition to `:messages`. + - `add` (local): post-install exec failure warns. + - `declare`: invalid URL format warns (both sync and async batch paths). + - `backup`: `cp` and `git add` results are checked and warn. + - `util#ensure_directory`: mkdir failures warn. + +### Added +- `tests/log_warnings.vader`: 15 regression tests asserting the WARN line + exists with `debug_mode` unset (contract from issue #5). + ## [2.2.7] - 2026-09-19 ### Fixed diff --git a/autoload/plugin_manager/async.vim b/autoload/plugin_manager/async.vim index 7672ce7..016ef67 100644 --- a/autoload/plugin_manager/async.vim +++ b/autoload/plugin_manager/async.vim @@ -350,7 +350,10 @@ function! s:process_job_completion(job_id) abort \ 'cmd': l:job.cmd \ }) catch - " Handle callback errors + " Handle callback errors: surface on :messages and in the log (a + " swallowed callback error must never be invisible). + call plugin_manager#core#log#warn('async', + \ 'async callback error: ' . v:exception) echohl ErrorMsg echomsg "Error in async callback: " . v:exception echohl None diff --git a/autoload/plugin_manager/cmd/add.vim b/autoload/plugin_manager/cmd/add.vim index 6c7fdc3..7fa7e8e 100644 --- a/autoload/plugin_manager/cmd/add.vim +++ b/autoload/plugin_manager/cmd/add.vim @@ -131,6 +131,9 @@ function! s:install_local_plugin(path, options) abort if !empty(get(a:options, 'exec', '')) let l:result = plugin_manager#core#util#run_in_dir(a:options.exec, l:plugin_dir) if !l:result.success + call plugin_manager#ui#log_detail('add', + \ 'post-install exec failed: ' . a:options.exec . ' - ' + \ . l:result.output, 'warn') call plugin_manager#ui#complete_operation(l:op_id, 'fail', 'Exec failed') return 0 endif diff --git a/autoload/plugin_manager/cmd/backup.vim b/autoload/plugin_manager/cmd/backup.vim index 2327e13..e5683aa 100644 --- a/autoload/plugin_manager/cmd/backup.vim +++ b/autoload/plugin_manager/cmd/backup.vim @@ -48,8 +48,17 @@ function! s:backup_vimrc_file() abort if plugin_manager#core#util#file_exists(l:vimrc_path) let l:copy_cmd = 'cp ' . shellescape(l:vimrc_path) . ' ' . shellescape(l:local_vimrc) let l:vim_dir = plugin_manager#core#util#get_config('vim_dir', '') - call plugin_manager#core#util#run_in_dir(l:copy_cmd, '') - call plugin_manager#git#execute('git add ' . shellescape(l:local_vimrc), l:vim_dir, 0, 0) + let l:copy_result = plugin_manager#core#util#run_in_dir(l:copy_cmd, '') + if !l:copy_result.success + call plugin_manager#ui#log_detail('backup', + \ 'backup cp failed: ' . l:copy_result.output, 'warn') + endif + let l:add_result = plugin_manager#git#execute( + \ 'git add ' . shellescape(l:local_vimrc), l:vim_dir, 0, 0) + if !l:add_result.success + call plugin_manager#ui#log_detail('backup', + \ 'backup git add failed: ' . l:add_result.output, 'warn') + endif endif endfunction diff --git a/autoload/plugin_manager/cmd/check.vim b/autoload/plugin_manager/cmd/check.vim index fdfbe24..3dbf76c 100644 --- a/autoload/plugin_manager/cmd/check.vim +++ b/autoload/plugin_manager/cmd/check.vim @@ -220,7 +220,10 @@ function! s:finish(plugins, opts) abort try call a:opts.on_done(a:plugins) catch - " Do not let callback failures bubble up + " Do not let callback failures bubble up, but leave a trace: a + " swallowed callback error must never be invisible. + call plugin_manager#core#log#warn('check', + \ 'on_done callback failed: ' . v:exception) endtry endif endfunction diff --git a/autoload/plugin_manager/cmd/declare.vim b/autoload/plugin_manager/cmd/declare.vim index 2e37759..3e3860a 100644 --- a/autoload/plugin_manager/cmd/declare.vim +++ b/autoload/plugin_manager/cmd/declare.vim @@ -140,6 +140,8 @@ function! s:process_declarations_async() abort if empty(l:full_url) || empty(l:name) let l:op_id = plugin_manager#ui#start_operation( \ empty(l:name) ? fnamemodify(l:plugin.url, ':t') : l:name, 'Processing') + call plugin_manager#ui#log_detail('declare', + \ 'invalid URL format: ' . l:plugin.url, 'warn') call plugin_manager#ui#complete_operation(l:op_id, 'fail', 'Invalid URL format') let l:ctx.errors += 1 continue @@ -266,6 +268,8 @@ function! s:process_plugin(url, options) abort let l:full_url = plugin_manager#core#util#convert_to_full_url(a:url) if empty(l:full_url) let l:plugin_name = fnamemodify(a:url, ':t') + call plugin_manager#ui#log_detail('declare', + \ 'invalid URL format: ' . a:url, 'warn') let l:op_id = plugin_manager#ui#start_operation(l:plugin_name, 'Processing') call plugin_manager#ui#complete_operation(l:op_id, 'fail', 'Invalid URL format') return 'error' diff --git a/autoload/plugin_manager/cmd/helptags.vim b/autoload/plugin_manager/cmd/helptags.vim index 4f7de8e..89f56b5 100644 --- a/autoload/plugin_manager/cmd/helptags.vim +++ b/autoload/plugin_manager/cmd/helptags.vim @@ -123,6 +123,12 @@ function! s:generate_for_plugin(plugin_name, plugin_path, ...) abort if s:generate_helptag(a:plugin_path) call plugin_manager#ui#complete_operation(l:op_id, 'ok', 'Helptags generated') return 1 + elseif isdirectory(a:plugin_path . '/doc') + " The doc directory exists but :helptags failed: that is a failure, + " not a skip - label it truthfully (details in the log). + call plugin_manager#ui#complete_operation(l:op_id, 'fail', + \ 'Helptags failed (see log)') + return 0 else call plugin_manager#ui#complete_operation(l:op_id, 'skip', 'No doc directory') return 0 @@ -140,6 +146,10 @@ function! s:generate_helptag(plugin_path) abort execute 'helptags ' . fnameescape(l:doc_path) return 1 catch + " A failed :helptags used to be indistinguishable from a missing doc + " directory: warn so the real error is visible in a default setup. + call plugin_manager#core#log#warn('helptags', + \ 'helptags failed in ' . a:plugin_path . ': ' . v:exception) return 0 endtry endif diff --git a/autoload/plugin_manager/cmd/remove.vim b/autoload/plugin_manager/cmd/remove.vim index 3e74fa6..a260089 100644 --- a/autoload/plugin_manager/cmd/remove.vim +++ b/autoload/plugin_manager/cmd/remove.vim @@ -163,34 +163,49 @@ function! s:remove_module(module_name, module_path) abort let l:module_info = s:get_module_metadata(a:module_path) " git submodule deinit and git rm take the repo-relative path as argument - " but must run inside the repo root (vim_dir). - call plugin_manager#git#execute( - \ 'git submodule deinit -f ' . shellescape(a:module_path), l:vim_dir, 0, 0) + " but must run inside the repo root (vim_dir). Discovery may hand us an + " absolute path (filesystem search): normalize so the vim_dir prefix is + " never applied twice. + let l:rel_path = plugin_manager#core#util#make_relative_path(a:module_path) + + let l:deinit_result = plugin_manager#git#execute( + \ 'git submodule deinit -f ' . shellescape(l:rel_path), l:vim_dir, 0, 0) + if !l:deinit_result.success + call plugin_manager#ui#log_detail('remove', + \ 'submodule deinit failed for ' . l:rel_path . ': ' + \ . l:deinit_result.output, 'warn') + endif let l:result = plugin_manager#git#execute( - \ 'git rm -f ' . shellescape(a:module_path), l:vim_dir, 0, 0) + \ 'git rm -f ' . shellescape(l:rel_path), l:vim_dir, 0, 0) if !l:result.success " Fallback: delete the working tree directory directly using absolute path - let l:abs_path = empty(l:vim_dir) ? a:module_path : (l:vim_dir . '/' . a:module_path) + let l:abs_path = empty(l:vim_dir) ? l:rel_path : (l:vim_dir . '/' . l:rel_path) call plugin_manager#ui#log_detail('remove', 'git rm failed, removing path manually: ' . l:abs_path, 'warn') call plugin_manager#core#util#remove_path(l:abs_path) endif " Remove the cached git metadata for this submodule (absolute path) - let l:git_modules_path = l:vim_dir . '/.git/modules/' . a:module_path + let l:git_modules_path = l:vim_dir . '/.git/modules/' . l:rel_path if plugin_manager#core#util#dir_exists(l:git_modules_path) call plugin_manager#core#util#remove_path(l:git_modules_path) endif " Removal outcome: the working tree directory must be gone. Report the " truth instead of an unconditional 'ok'. - let l:abs_module = empty(l:vim_dir) ? a:module_path : (l:vim_dir . '/' . a:module_path) + let l:abs_module = empty(l:vim_dir) ? l:rel_path : (l:vim_dir . '/' . l:rel_path) let l:success = !plugin_manager#core#util#dir_exists(l:abs_module) if l:success - call s:commit_removal(a:module_name, l:module_info) - call plugin_manager#ui#complete_operation(l:op_id, 'ok', 'Removed') - call plugin_manager#ui#footer([plugin_manager#ui#success('Plugin removed')]) + if s:commit_removal(a:module_name, l:module_info) + call plugin_manager#ui#complete_operation(l:op_id, 'ok', 'Removed') + call plugin_manager#ui#footer([plugin_manager#ui#success('Plugin removed')]) + else + call plugin_manager#ui#complete_operation(l:op_id, 'warn', + \ 'Removed (pointer commit failed: see log)') + call plugin_manager#ui#footer([plugin_manager#ui#warning( + \ 'Plugin removed, but the pointer commit failed (see log)')]) + endif else call plugin_manager#ui#complete_operation(l:op_id, 'fail', \ 'Removal incomplete: ' . l:abs_module . ' still exists (see log)') @@ -216,6 +231,9 @@ function! s:get_module_metadata(module_path) abort return {} endfunction +" Stage the .gitmodules update and record the removal as its own history +" entry. Returns 1 when a commit was created, 0 when every commit attempt +" failed (the failure is logged: the pointer state is degraded). function! s:commit_removal(module_name, module_info) abort let l:commit_msg = "Remove " . a:module_name . " plugin" let l:vim_dir = plugin_manager#core#util#get_config('vim_dir', '') @@ -225,13 +243,25 @@ function! s:commit_removal(module_name, module_info) abort endif " Stage .gitmodules (updated by git rm); run in vim_dir for repo-root scope. - call plugin_manager#git#execute('git add .gitmodules', l:vim_dir, 0, 0) + let l:add_result = plugin_manager#git#execute( + \ 'git add .gitmodules', l:vim_dir, 0, 0) + if !l:add_result.success + call plugin_manager#ui#log_detail('remove', + \ 'git add .gitmodules failed: ' . l:add_result.output, 'warn') + endif " Try to commit; if nothing to commit (already removed via rm), create an " empty commit so the removal is recorded as a separate history entry. let l:result = plugin_manager#git#execute( \ 'git commit -m ' . shellescape(l:commit_msg), l:vim_dir, 0, 0) if !l:result.success - call plugin_manager#git#execute( + let l:result = plugin_manager#git#execute( \ 'git commit --allow-empty -m ' . shellescape(l:commit_msg), l:vim_dir, 0, 0) endif + if !l:result.success + call plugin_manager#ui#log_detail('remove', + \ 'pointer commit failed for ' . a:module_name . ': ' + \ . l:result.output, 'warn') + return 0 + endif + return 1 endfunction \ No newline at end of file diff --git a/autoload/plugin_manager/cmd/update.vim b/autoload/plugin_manager/cmd/update.vim index 26aa17b..a604055 100644 --- a/autoload/plugin_manager/cmd/update.vim +++ b/autoload/plugin_manager/cmd/update.vim @@ -159,7 +159,10 @@ function! s:on_update_complete(ctx, result) abort call plugin_manager#ui#complete_operation(l:op_id, 'ok', 'Updated') call plugin_manager#cmd#helptags#execute(0, l:module_name, 1) let l:module_path = get(a:ctx.current_module, 'path', '') - call s:commit_update_async(l:module_name, l:module_path) + if s:commit_update_async(l:module_name, l:module_path) + call plugin_manager#ui#complete_operation(l:op_id, 'warn', + \ 'Updated (auto-commit failed: see log)') + endif else call plugin_manager#ui#complete_operation(l:op_id, 'info', 'Up-to-date') endif @@ -239,6 +242,9 @@ function! s:sync_pinned(ctx, module, pin, current_commit, op_id) abort let l:res = plugin_manager#git#execute( \ 'git rev-parse ' . shellescape(l:ref . '^{commit}'), l:module_path, 0, 0) if !l:res.success + " The sidebar alone hides this from :PluginManagerViewLog: warn too. + call plugin_manager#core#log#warn('update', + \ 'Pin target not found: ' . l:ref . ' in ' . l:module_path) call plugin_manager#ui#complete_operation(a:op_id, 'fail', \ 'Pin target not found: ' . l:ref) call s:pin_done(a:ctx, a:module, 0) @@ -299,7 +305,10 @@ function! s:pin_done(ctx, module, changed) abort elseif a:changed call plugin_manager#cmd#helptags#execute(0, a:module.short_name, 1) let l:module_path = get(get(a:ctx, 'current_module', {}), 'path', '') - call s:commit_update_async(a:module.short_name, l:module_path) + if s:commit_update_async(a:module.short_name, l:module_path) + call plugin_manager#ui#complete_operation(s:op_id_for(a:ctx, a:module), + \ 'warn', 'Updated (auto-commit failed: see log)') + endif endif endfunction @@ -485,25 +494,37 @@ function! s:maybe_finalize(ctx) abort endfunction function! s:finalize_update_all(ctx) abort + let l:commit_failed = 0 if !empty(a:ctx.updated_modules) if plugin_manager#core#util#should_auto_commit() let l:vd = plugin_manager#core#util#get_config('vim_dir', '') - call s:log_silent_failure('git add .gitmodules', + let l:commit_failed = s:log_silent_failure('git add .gitmodules', \ plugin_manager#git#execute('git add .gitmodules', l:vd, 0, 0)) for l:module in a:ctx.updated_modules - call s:log_silent_failure('git add ' . l:module.path, + let l:commit_failed = s:log_silent_failure('git add ' . l:module.path, \ plugin_manager#git#execute( \ 'git add ' . shellescape(l:module.path), l:vd, 0, 0)) + \ || l:commit_failed endfor - call s:log_silent_failure('commit', + let l:commit_failed = s:log_silent_failure('commit', \ plugin_manager#git#execute( \ 'git commit -m "Update Modules"', l:vd, 0, 0)) + \ || l:commit_failed endif for l:module in a:ctx.updated_modules call plugin_manager#cmd#helptags#execute(0, l:module.short_name, 1) endfor endif + " Truthful completion: the plugins updated, but the pointer commit is the + " part that persists the update - a failure must not read as a clean win. + if l:commit_failed + for l:module in a:ctx.updated_modules + call plugin_manager#ui#complete_operation(a:ctx.ops[l:module.short_name], + \ 'warn', 'Updated (auto-commit failed: see log)') + endfor + endif + let l:n = len(a:ctx.updated_modules) let l:total = len(a:ctx.valid_modules) let l:failed = len(get(a:ctx, 'fetch_failed', {})) @@ -520,6 +541,10 @@ function! s:finalize_update_all(ctx) abort call add(l:footer, plugin_manager#ui#warning( \ l:failed . ' of ' . l:total . ' plugins failed to fetch')) endif + if l:commit_failed + call add(l:footer, plugin_manager#ui#warning( + \ 'auto-commit failed (see log): the update is not recorded in git')) + endif call plugin_manager#ui#footer(l:footer) endfunction @@ -529,11 +554,14 @@ endfunction " Log silent git failures (git#execute with throw_on_error=0): a failed " pointer add/commit must leave a trace in the log, not vanish. +" Returns 1 when the step failed, 0 otherwise. function! s:log_silent_failure(step, res) abort if !a:res.success call plugin_manager#ui#log_detail('update', \ 'auto-commit ' . a:step . ' failed: ' . a:res.output, 'warn') + return 1 endif + return 0 endfunction " Stash local changes if any exist. Returns 1 if a stash was created, 0 otherwise. @@ -545,7 +573,14 @@ function! s:stash_if_needed(module_path) abort if !l:status.success || empty(trim(l:status.output)) return 0 endif - call plugin_manager#git#execute('git stash push -u -q', a:module_path, 0, 0) + let l:stash_result = plugin_manager#git#execute('git stash push -u -q', a:module_path, 0, 0) + if !l:stash_result.success + " Never claim a stash exists when the push failed: the caller would + " otherwise pop an unrelated stash after the pull. + call plugin_manager#ui#log_detail('update', + \ 'stash push failed in ' . a:module_path . ': ' . l:stash_result.output, 'warn') + return 0 + endif return 1 endfunction @@ -562,21 +597,27 @@ function! s:stash_pop(module_path, op_id) abort endif endfunction +" Auto-commit the pointer for a single updated module (mirror of +" s:finalize_update_all). Returns 1 when any step failed so the caller can +" re-complete the operation with a truthful warn. function! s:commit_update_async(module_name, module_path) abort if !plugin_manager#core#util#should_auto_commit() - return + return 0 endif let l:vim_dir = plugin_manager#core#util#get_config('vim_dir', '') " Three separate calls (mirrors s:finalize_update_all): a compound command " would only scope the first git -C, and the module name must stay escaped " so it can never break out of the commit message quoting. - call s:log_silent_failure('git add .gitmodules', + let l:failed = s:log_silent_failure('git add .gitmodules', \ plugin_manager#git#execute('git add .gitmodules', l:vim_dir, 0, 0)) - call s:log_silent_failure('git add ' . a:module_path, + let l:failed = s:log_silent_failure('git add ' . a:module_path, \ plugin_manager#git#execute( \ 'git add ' . shellescape(a:module_path), l:vim_dir, 0, 0)) - call s:log_silent_failure('commit', + \ || l:failed + let l:failed = s:log_silent_failure('commit', \ plugin_manager#git#execute( \ 'git commit -m ' . shellescape('Update Module: ' . a:module_name), \ l:vim_dir, 0, 0)) + \ || l:failed + return l:failed endfunction diff --git a/autoload/plugin_manager/core/util.vim b/autoload/plugin_manager/core/util.vim index fcc16a3..cc0522e 100644 --- a/autoload/plugin_manager/core/util.vim +++ b/autoload/plugin_manager/core/util.vim @@ -90,6 +90,10 @@ function! plugin_manager#core#util#ensure_directory(dir) abort call mkdir(l:dir, 'p') return 1 catch + " A silent mkdir failure cascades into confusing downstream errors: + " warn so the real cause is visible. + call plugin_manager#core#log#warn('util', + \ 'mkdir failed: ' . l:dir . ': ' . v:exception) return 0 endtry endif diff --git a/autoload/plugin_manager/git.vim b/autoload/plugin_manager/git.vim index 1fbf3c2..a62ecd5 100644 --- a/autoload/plugin_manager/git.vim +++ b/autoload/plugin_manager/git.vim @@ -563,7 +563,9 @@ function! plugin_manager#git#add_submodule(url, install_dir, options) abort endif endif - " Commit changes (must run at repo root) + " Commit changes (must run at repo root). A failed pointer commit + " leaves the submodule unrecorded: never swallow it - warn and fail the + " add so the caller reports the degraded outcome. let l:commit_msg = 'Add ' . a:url . ' plugin' if !empty(a:options.branch) let l:commit_msg .= ' (branch: ' . a:options.branch . ')' @@ -571,9 +573,15 @@ function! plugin_manager#git#add_submodule(url, install_dir, options) abort let l:commit_msg .= ' (tag: ' . a:options.tag . ')' endif - call plugin_manager#git#execute('git commit -m ' . shellescape(l:commit_msg), l:vim_dir, 1, 0) - - return l:result.success + let l:commit_result = plugin_manager#git#execute( + \ 'git commit -m ' . shellescape(l:commit_msg), l:vim_dir, 1, 0) + if !l:commit_result.success + call plugin_manager#core#log#warn('git', + \ 'pointer commit failed for ' . l:relative_path . ': ' + \ . l:commit_result.output) + endif + + return l:result.success && l:commit_result.success endfunction diff --git a/autoload/plugin_manager/vimrc.vim b/autoload/plugin_manager/vimrc.vim index 0f62807..e54282e 100644 --- a/autoload/plugin_manager/vimrc.vim +++ b/autoload/plugin_manager/vimrc.vim @@ -51,7 +51,9 @@ function! s:eval_declaration(rest) abort endif endif if empty(l:args) || type(get(l:args, 0, '')) != v:t_string - call plugin_manager#core#log#debug('vimrc', + " Warn, not debug: a silently skipped declaration must be visible in a + " default setup (debug_mode off), same contract as issue #5. + call plugin_manager#core#log#warn('vimrc', \ 'skipping unparsable declaration: ' . a:rest) return {} endif diff --git a/tests/log_warnings.vader b/tests/log_warnings.vader new file mode 100644 index 0000000..dc1a805 --- /dev/null +++ b/tests/log_warnings.vader @@ -0,0 +1,321 @@ +" Regression tests: every degraded outcome must leave a WARN line in the log +" with debug_mode NOT set (contract from issue #5 / log#warn). Each Execute +" block starts by deleting the log so assertions are case-local. +" Fixture mirrors tests/update.vader (bare remote + vim repo + submodule). + +Before: + let g:_pm_lw_saved = { + \ 'vim_dir': get(g:, 'plugin_manager_vim_dir', ''), + \ 'cwd': getcwd(), + \ 'plugins_dir': get(g:, 'plugin_manager_plugins_dir', ''), + \ 'vimrc': get(g:, 'plugin_manager_vimrc_path', ''), + \ 'logging': get(g:, 'plugin_manager_enable_logging', 0), + \ 'auto_commit': get(g:, 'plugin_manager_auto_commit_on_update', 1), + \ } + + let g:_pm_lw_root = tempname() + let g:_pm_lw_bare = g:_pm_lw_root . '/myplugin.git' + let g:_pm_lw_vim = g:_pm_lw_root . '/vim' + let g:_pm_lw_plug = g:_pm_lw_vim . '/pack/plugins/start/myplugin' + let g:_pm_lw_src = g:_pm_lw_root . '/src' + let g:_pm_lw_vimrc = g:_pm_lw_root . '/ext-vimrc' + let g:_pm_lw_local = g:_pm_lw_root . '/localsrc' + let g:_pm_lw_log = g:_pm_lw_vim . '/logs/plugin_manager.log' + + call mkdir(g:_pm_lw_root, 'p') + + " 1. Bare remote (named myplugin.git so extract_plugin_name matches) + call system('git init --bare ' . shellescape(g:_pm_lw_bare)) + call system('git -C ' . shellescape(g:_pm_lw_bare) . ' symbolic-ref HEAD refs/heads/main') + + " 2. Source working tree: initial commit pushed to the bare + call mkdir(g:_pm_lw_src, 'p') + call system('git init ' . shellescape(g:_pm_lw_src)) + call system('git -C ' . shellescape(g:_pm_lw_src) . ' config user.email "test@test.com"') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' config user.name "Test"') + call system('git -C ' . shellescape(g:_pm_lw_src) + \ . ' remote add origin ' . shellescape(g:_pm_lw_bare)) + call writefile(['v1'], g:_pm_lw_src . '/file.txt') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' add .') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' commit -m "initial"') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' push -q origin HEAD:main ' + \ . '2>/dev/null || git -C ' . shellescape(g:_pm_lw_src) + \ . ' push -q --set-upstream origin HEAD:main') + call system('git -C ' . shellescape(g:_pm_lw_bare) . ' symbolic-ref HEAD refs/heads/main') + + " 3. Vim config dir as a git repo + call mkdir(g:_pm_lw_vim, 'p') + call system('git init ' . shellescape(g:_pm_lw_vim)) + call system('git -C ' . shellescape(g:_pm_lw_vim) . ' config user.email "test@test.com"') + call system('git -C ' . shellescape(g:_pm_lw_vim) . ' config user.name "Test"') + + " 4. Submodule + local plugin source + call mkdir(g:_pm_lw_vim . '/pack/plugins/start', 'p') + call system('git -C ' . shellescape(g:_pm_lw_vim) + \ . ' -c protocol.file.allow=always submodule add ' + \ . shellescape('file://' . g:_pm_lw_bare) . ' pack/plugins/start/myplugin') + call system('git -C ' . shellescape(g:_pm_lw_vim) . ' commit -m "add submodule"') + call mkdir(g:_pm_lw_local, 'p') + call writefile(['x'], g:_pm_lw_local . '/code.vim') + + " 5. Point the manager at the fixture. debug_mode is deliberately NOT + " set: the WARN contract must hold in a default setup. + let g:plugin_manager_vim_dir = g:_pm_lw_vim + let g:plugin_manager_plugins_dir = g:_pm_lw_vim . '/pack/plugins' + let g:plugin_manager_vimrc_path = g:_pm_lw_root . '/absent.vimrc' + let g:plugin_manager_enable_logging = 1 + unlet! g:plugin_manager_debug_mode + " Self-healing: an earlier aborted test can leave an emptied git identity + " in the environment, which would break every fixture commit below. + unlet! $GIT_AUTHOR_NAME $GIT_AUTHOR_EMAIL + unlet! $GIT_COMMITTER_NAME $GIT_COMMITTER_EMAIL + let g:plugin_manager_test_force_sync = 1 + + execute 'cd ' . fnameescape(g:_pm_lw_vim) + + let s:_pm_lw_buf = bufnr('PluginManager') + if s:_pm_lw_buf != -1 + execute 'bwipeout! ' . s:_pm_lw_buf + endif + + call plugin_manager#git#refresh_modules_cache() + + " Read the whole log as one string (empty when the file does not exist). + function! g:_pm_lw_logtext() abort + if filereadable(g:_pm_lw_log) + return join(readfile(g:_pm_lw_log), "\n") + endif + return '' + endfunction + +After: + execute 'cd ' . fnameescape(g:_pm_lw_saved.cwd) + if isdirectory(g:_pm_lw_root) + call delete(g:_pm_lw_root, 'rf') + endif + unlet! $GIT_AUTHOR_NAME $GIT_AUTHOR_EMAIL + unlet! $GIT_COMMITTER_NAME $GIT_COMMITTER_EMAIL + let g:plugin_manager_vim_dir = g:_pm_lw_saved.vim_dir + let g:plugin_manager_plugins_dir = g:_pm_lw_saved.plugins_dir + let g:plugin_manager_vimrc_path = g:_pm_lw_saved.vimrc + let g:plugin_manager_enable_logging = g:_pm_lw_saved.logging + let g:plugin_manager_auto_commit_on_update = g:_pm_lw_saved.auto_commit + unlet! g:plugin_manager_test_force_sync + unlet g:_pm_lw_saved g:_pm_lw_root g:_pm_lw_bare g:_pm_lw_vim g:_pm_lw_plug + unlet g:_pm_lw_src g:_pm_lw_vimrc g:_pm_lw_local g:_pm_lw_log + let s:_pm_lw_buf2 = bufnr('PluginManager') + if s:_pm_lw_buf2 != -1 + execute 'bwipeout! ' . s:_pm_lw_buf2 + endif + call plugin_manager#git#refresh_modules_cache() + +Execute (ensure_directory logs a WARN when mkdir fails): + call delete(g:_pm_lw_log) + call writefile(['x'], g:_pm_lw_root . '/blocker') + call plugin_manager#core#util#ensure_directory(g:_pm_lw_root . '/blocker/child') + Assert g:_pm_lw_logtext() =~# 'mkdir failed', + \ 'mkdir failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (unparsable vimrc declaration logs a WARN, not a gated debug): + call delete(g:_pm_lw_log) + call writefile(["Plugin 'unclosed-string"], g:_pm_lw_vimrc) + let g:plugin_manager_vimrc_path = g:_pm_lw_vimrc + call plugin_manager#vimrc#parse_declarations() + let g:plugin_manager_vimrc_path = g:_pm_lw_root . '/absent.vimrc' + Assert g:_pm_lw_logtext() =~# 'skipping unparsable declaration', + \ 'unparsable declaration must warn, got: ' . g:_pm_lw_logtext() + +Execute (check on_done callback failure logs a WARN): + call delete(g:_pm_lw_log) + function! g:_pm_lw_boom(...) abort + throw 'on-done-boom' + endfunction + call plugin_manager#cmd#check#execute( + \ {'silent': 1, 'on_done': function('g:_pm_lw_boom')}) + delfunction g:_pm_lw_boom + Assert g:_pm_lw_logtext() =~# 'on_done callback failed', + \ 'on_done failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (async callback error logs a WARN): + call delete(g:_pm_lw_log) + unlet g:plugin_manager_test_force_sync + if !plugin_manager#async#supported() + let g:plugin_manager_test_force_sync = 1 + Log 'Skipping: async not supported on this Vim' + finish + endif + function! g:_pm_lw_cbboom(result) abort + throw 'async-callback-boom' + endfunction + let g:plugin_manager_max_concurrent_jobs = 1 + " Job 1 fills the slot; job 2 queues. Stopping the queued job runs + " s:process_job_completion synchronously, which invokes (and catches) + " the throwing callback - no event loop needed. + let g:_pm_lw_j1 = plugin_manager#async#start_job('sleep 5', {}) + let g:_pm_lw_j2 = plugin_manager#async#start_job( + \ 'echo x', {'callback': function('g:_pm_lw_cbboom')}) + call plugin_manager#async#stop_job(g:_pm_lw_j2) + call plugin_manager#async#stop_job(g:_pm_lw_j1) + delfunction g:_pm_lw_cbboom + unlet g:_pm_lw_j1 g:_pm_lw_j2 + Assert g:_pm_lw_logtext() =~# 'async-callback-boom', + \ 'async callback failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (real helptags failure logs a WARN and is not labeled "No doc directory"): + call delete(g:_pm_lw_log) + call mkdir(g:_pm_lw_plug . '/doc', 'p') + " A valid help file forces :helptags to (re)write the tags file; the + " read-only tags file then makes the write fail with E152. + call writefile(['*myplugin-help*'], g:_pm_lw_plug . '/doc/myplugin.txt') + call writefile(['seed'], g:_pm_lw_plug . '/doc/tags') + call setfperm(g:_pm_lw_plug . '/doc/tags', 'r--r--r--') + call plugin_manager#cmd#helptags#execute(1) + let g:_pm_lw_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") + Assert g:_pm_lw_sb =~# 'Helptags failed', + \ 'real failure must not read "No doc directory", got: ' . g:_pm_lw_sb + Assert g:_pm_lw_logtext() =~# 'helptags failed', + \ 'helptags failure must be logged, got: ' . g:_pm_lw_logtext() + unlet g:_pm_lw_sb + +Execute (local post-install exec failure logs a WARN): + call delete(g:_pm_lw_log) + " A plain existing directory path is the local-plugin input form. + call plugin_manager#cmd#add#execute(g:_pm_lw_local, {'exec': 'false'}) + Assert g:_pm_lw_logtext() =~# 'post-install exec failed', + \ 'exec failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (declare invalid URL logs a WARN - synchronous path): + call delete(g:_pm_lw_log) + call plugin_manager#cmd#declare#begin() + call plugin_manager#cmd#declare#plugin('://nope') + call plugin_manager#cmd#declare#end() + Assert g:_pm_lw_logtext() =~# 'invalid URL format', + \ 'invalid URL (sync) must be logged, got: ' . g:_pm_lw_logtext() + +Execute (declare invalid URL logs a WARN - async batch path): + call delete(g:_pm_lw_log) + unlet g:plugin_manager_test_force_sync + if !plugin_manager#async#supported() + let g:plugin_manager_test_force_sync = 1 + Log 'Skipping: async not supported on this Vim' + finish + endif + call plugin_manager#cmd#declare#begin() + call plugin_manager#cmd#declare#plugin('://nope') + call plugin_manager#cmd#declare#end() + let g:plugin_manager_test_force_sync = 1 + Assert g:_pm_lw_logtext() =~# 'invalid URL format', + \ 'invalid URL (async batch) must be logged, got: ' . g:_pm_lw_logtext() + +Execute (backup git add failure logs a WARN): + call delete(g:_pm_lw_log) + call writefile(['set nocompatible'], g:_pm_lw_vimrc) + let g:plugin_manager_vimrc_path = g:_pm_lw_vimrc + call writefile(['lock'], g:_pm_lw_vim . '/.git/index.lock') + call plugin_manager#cmd#backup#execute() + call delete(g:_pm_lw_vim . '/.git/index.lock') + let g:plugin_manager_vimrc_path = g:_pm_lw_root . '/absent.vimrc' + Assert g:_pm_lw_logtext() =~# 'git add failed', + \ 'backup git add failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (removing a non-submodule dir logs a WARN when deinit fails): + call delete(g:_pm_lw_log) + call mkdir(g:_pm_lw_vim . '/pack/plugins/start/plainplugin', 'p') + call writefile(['x'], g:_pm_lw_vim . '/pack/plugins/start/plainplugin/a.txt') + call plugin_manager#cmd#remove#execute('plainplugin', '-f') + Assert g:_pm_lw_logtext() =~# 'deinit failed', + \ 'deinit failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (remove pointer commit failure logs a WARN and the op is not a plain "Removed"): + call delete(g:_pm_lw_log) + let $GIT_AUTHOR_NAME = '' + let $GIT_AUTHOR_EMAIL = '' + let $GIT_COMMITTER_NAME = '' + let $GIT_COMMITTER_EMAIL = '' + call plugin_manager#cmd#remove#execute('myplugin', '-f') + unlet $GIT_AUTHOR_NAME + unlet $GIT_AUTHOR_EMAIL + unlet $GIT_COMMITTER_NAME + unlet $GIT_COMMITTER_EMAIL + let g:_pm_lw_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") + Assert g:_pm_lw_logtext() =~# 'pointer commit failed', + \ 'remove commit failure must be logged, got: ' . g:_pm_lw_logtext() + Assert g:_pm_lw_sb =~# 'pointer commit failed', + \ 'op must not claim a plain Removed, got: ' . g:_pm_lw_sb + unlet g:_pm_lw_sb + +Execute (add_submodule pointer commit failure logs a WARN and fails the add): + call delete(g:_pm_lw_log) + " Unit-level: the add#execute probe (ls-remote) is bypassed; the site + " under test is the discarded pointer commit in git#add_submodule. + let $GIT_AUTHOR_NAME = '' + let $GIT_AUTHOR_EMAIL = '' + let $GIT_COMMITTER_NAME = '' + let $GIT_COMMITTER_EMAIL = '' + let g:_pm_lw_r = plugin_manager#git#add_submodule( + \ 'file://' . g:_pm_lw_bare, + \ g:_pm_lw_vim . '/pack/plugins/start/pointerplugin', + \ plugin_manager#core#util#process_plugin_options([])) + unlet $GIT_AUTHOR_NAME + unlet $GIT_AUTHOR_EMAIL + unlet $GIT_COMMITTER_NAME + unlet $GIT_COMMITTER_EMAIL + AssertEqual 0, g:_pm_lw_r, 'pointer commit failure must fail the add' + Assert g:_pm_lw_logtext() =~# 'pointer commit failed', + \ 'add pointer commit failure must be logged, got: ' . g:_pm_lw_logtext() + unlet g:_pm_lw_r + +Execute (update with an unreachable pin target logs a WARN): + call delete(g:_pm_lw_log) + call writefile([ + \ "Plugin 'file://" . g:_pm_lw_bare . "', {'tag': 'v-nope'}" + \ ], g:_pm_lw_vimrc) + let g:plugin_manager_vimrc_path = g:_pm_lw_vimrc + call plugin_manager#cmd#update#execute('myplugin') + let g:plugin_manager_vimrc_path = g:_pm_lw_root . '/absent.vimrc' + Assert g:_pm_lw_logtext() =~# 'Pin target not found', + \ 'pin target failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (stash push failure logs a WARN and does not pretend the stash exists): + call delete(g:_pm_lw_log) + " Push v2 so the module has updates, then dirty the module worktree. + call writefile(['v2'], g:_pm_lw_src . '/file.txt') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' commit -aqm "v2"') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' push -q origin HEAD:main') + call writefile(['dirty'], g:_pm_lw_plug . '/file.txt') + " Empty identity makes `git stash push` fail (it creates commits). + let $GIT_AUTHOR_NAME = '' + let $GIT_AUTHOR_EMAIL = '' + let $GIT_COMMITTER_NAME = '' + let $GIT_COMMITTER_EMAIL = '' + call plugin_manager#cmd#update#execute('myplugin') + unlet $GIT_AUTHOR_NAME + unlet $GIT_AUTHOR_EMAIL + unlet $GIT_COMMITTER_NAME + unlet $GIT_COMMITTER_EMAIL + Assert g:_pm_lw_logtext() =~# 'stash push failed', + \ 'stash push failure must be logged, got: ' . g:_pm_lw_logtext() + +Execute (auto-commit failure completes the op with a truthful warn): + call delete(g:_pm_lw_log) + let g:plugin_manager_auto_commit_on_update = 1 + call writefile(['v3'], g:_pm_lw_src . '/file.txt') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' commit -aqm "v3"') + call system('git -C ' . shellescape(g:_pm_lw_src) . ' push -q origin HEAD:main') + " Empty identity: the module pull (fast-forward) succeeds but the parent + " repo auto-commit fails. + let $GIT_AUTHOR_NAME = '' + let $GIT_AUTHOR_EMAIL = '' + let $GIT_COMMITTER_NAME = '' + let $GIT_COMMITTER_EMAIL = '' + call plugin_manager#cmd#update#execute('all') + unlet $GIT_AUTHOR_NAME + unlet $GIT_AUTHOR_EMAIL + unlet $GIT_COMMITTER_NAME + unlet $GIT_COMMITTER_EMAIL + let g:_pm_lw_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") + Assert g:_pm_lw_logtext() =~# 'auto-commit commit failed', + \ 'auto-commit failure must be logged, got: ' . g:_pm_lw_logtext() + Assert g:_pm_lw_sb =~# 'auto-commit failed', + \ 'sidebar must reflect the auto-commit failure, got: ' . g:_pm_lw_sb + unlet g:_pm_lw_sb From 53d07fd502f5610bf5acf5b1adb69a4a7e57c1f4 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:30:24 +0200 Subject: [PATCH 2/2] test: make the helptags failure trigger root-proof for CI Permission bits do not block root (CI containers run as root): a tags DIRECTORY plus a valid help file makes :helptags fail with E152 for every user. --- tests/log_warnings.vader | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/log_warnings.vader b/tests/log_warnings.vader index dc1a805..17c937a 100644 --- a/tests/log_warnings.vader +++ b/tests/log_warnings.vader @@ -164,11 +164,11 @@ Execute (async callback error logs a WARN): Execute (real helptags failure logs a WARN and is not labeled "No doc directory"): call delete(g:_pm_lw_log) call mkdir(g:_pm_lw_plug . '/doc', 'p') - " A valid help file forces :helptags to (re)write the tags file; the - " read-only tags file then makes the write fail with E152. + " A valid help file forces :helptags to (re)write the tags file; a tags + " DIRECTORY makes that write fail (E152) for root and users alike - CI + " containers run as root, so permission bits are not a usable trigger. call writefile(['*myplugin-help*'], g:_pm_lw_plug . '/doc/myplugin.txt') - call writefile(['seed'], g:_pm_lw_plug . '/doc/tags') - call setfperm(g:_pm_lw_plug . '/doc/tags', 'r--r--r--') + call mkdir(g:_pm_lw_plug . '/doc/tags', 'p') call plugin_manager#cmd#helptags#execute(1) let g:_pm_lw_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") Assert g:_pm_lw_sb =~# 'Helptags failed',