From c947d8a209f38c96d1ef165ea005576d0427364e Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:35:41 +0200 Subject: [PATCH 01/10] test(perf): pin collect_status_local git-subprocess budget collect_status_local runs once per module inside status/update/check loops. Worst case today: 9 subprocesses (per-module .gitmodules config read + upstream/show-ref cascade). Pin budgets <= 7 / <= 6 (Red) and keep a behavior guard on the returned status dict. --- tests/perf.vader | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/perf.vader diff --git a/tests/perf.vader b/tests/perf.vader new file mode 100644 index 0000000..6fb839d --- /dev/null +++ b/tests/perf.vader @@ -0,0 +1,105 @@ +" Performance contract (tests/perf.vader): collect_status_local must batch +" its git subprocesses. It runs once per module inside status/update/check +" loops, so every call it makes is multiplied by the module count. +" Baseline (v2.2.10), worst case per module: 9 git#execute calls +" rev-parse HEAD, symbolic-ref, per-module `git config -f .gitmodules`, +" upstream rev-parse, show-ref cascade (x2 here), remote rev-parse, +" 2x rev-list --count, status -s. +" Budget after batching: <= 7 (no branch key) / <= 6 (branch in .gitmodules). +" +" Counting seam (non-invasive): with trace_commands + logging on, git#execute +" logs exactly one 'exec: ' line per subprocess. We count those lines. + +Before: + let g:_pm_perf_saved = { + \ 'vim_dir': get(g:, 'plugin_manager_vim_dir', ''), + \ 'plugins_dir': get(g:, 'plugin_manager_plugins_dir', ''), + \ 'logging': get(g:, 'plugin_manager_enable_logging', 0), + \ 'trace': get(g:, 'plugin_manager_trace_commands', 0), + \ } + let g:plugin_manager_vim_dir = '/tmp/pm-perf-test/vim' + let g:plugin_manager_plugins_dir = '/tmp/pm-perf-test/vim/pack/plugins' + let g:plugin_manager_enable_logging = 1 + let g:plugin_manager_trace_commands = 1 + unlet! g:plugin_manager_debug_mode + call delete('/tmp/pm-perf-test', 'rf') + call mkdir('/tmp/pm-perf-test/vim/pack/plugins/start', 'p') + + " Module repo on branch main with a remote-tracking origin/main and no + " upstream (worst-case remote-branch resolution: full cascade). + let g:_pm_perf_mod = '/tmp/pm-perf-test/vim/pack/plugins/start/foo' + call mkdir(g:_pm_perf_mod, 'p') + call system('git init -q ' . shellescape(g:_pm_perf_mod)) + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' config user.email "t@t.com"') + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' config user.name "T"') + call writefile(['x'], g:_pm_perf_mod . '/f') + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' add .') + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' commit -qm init') + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' symbolic-ref HEAD refs/heads/main') + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' update-ref refs/remotes/origin/main HEAD') + + " .gitmodules without a branch key: the branch must come from the cascade. + call writefile([ + \ '[submodule "pack/plugins/start/foo"]', + \ "\tpath = pack/plugins/start/foo", + \ "\turl = https://example.com/a/b.git", + \ ], '/tmp/pm-perf-test/vim/.gitmodules') + call plugin_manager#git#refresh_modules_cache() + + let g:_pm_perf_log = '/tmp/pm-perf-test/vim/logs/plugin_manager.log' + +After: + call delete('/tmp/pm-perf-test', 'rf') + let g:plugin_manager_vim_dir = g:_pm_perf_saved.vim_dir + let g:plugin_manager_plugins_dir = g:_pm_perf_saved.plugins_dir + let g:plugin_manager_enable_logging = g:_pm_perf_saved.logging + let g:plugin_manager_trace_commands = g:_pm_perf_saved.trace + unlet g:_pm_perf_saved + +Execute (collect_status_local result is unchanged on a plain module): + let g:_pm_perf_st = plugin_manager#git#collect_status_local(g:_pm_perf_mod) + AssertEqual 'main', g:_pm_perf_st.branch + AssertEqual 'origin/main', g:_pm_perf_st.remote_branch + Assert g:_pm_perf_st.current_commit !=# 'N/A' + Assert g:_pm_perf_st.remote_commit !=# 'N/A' + Assert g:_pm_perf_st.current_commit ==# g:_pm_perf_st.remote_commit + AssertEqual 0, g:_pm_perf_st.has_updates + AssertEqual 0, g:_pm_perf_st.has_changes + +Execute (subprocess budget without .gitmodules branch: <= 7 calls): + " Push HEAD ahead of origin/main so both rev-list calls fire (worst case). + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' commit --allow-empty -qm second') + call delete(g:_pm_perf_log) + call plugin_manager#git#collect_status_local(g:_pm_perf_mod) + let g:_pm_perf_cmds = map( + \ filter(readfile(g:_pm_perf_log), 'v:val =~# "exec: "'), + \ 'substitute(v:val, "^.*exec: ", "", "")') + Assert len(g:_pm_perf_cmds) <= 7, + \ 'collect_status_local made ' . len(g:_pm_perf_cmds) + \ . ' git calls: ' . join(g:_pm_perf_cmds, ' | ') + +Execute (subprocess budget with .gitmodules branch: <= 6 calls): + " Second commit: HEAD~1 must exist below. + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' commit --allow-empty -qm second') + " .gitmodules branch is used verbatim: `git rev-parse ` resolves + " the LOCAL ref of that name (v2.2.10 behavior, preserved). Make that + " local ref differ from HEAD so both rev-list calls fire (worst case). + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' update-ref refs/heads/master HEAD~1') + call writefile([ + \ '[submodule "pack/plugins/start/foo"]', + \ "\tpath = pack/plugins/start/foo", + \ "\turl = https://example.com/a/b.git", + \ "\tbranch = master", + \ ], '/tmp/pm-perf-test/vim/.gitmodules') + call plugin_manager#git#refresh_modules_cache() + let g:_pm_perf_st = plugin_manager#git#collect_status_local(g:_pm_perf_mod) + AssertEqual 'master', g:_pm_perf_st.remote_branch + AssertEqual 1, g:_pm_perf_st.has_updates + call delete(g:_pm_perf_log) + call plugin_manager#git#collect_status_local(g:_pm_perf_mod) + let g:_pm_perf_cmds = map( + \ filter(readfile(g:_pm_perf_log), 'v:val =~# "exec: "'), + \ 'substitute(v:val, "^.*exec: ", "", "")') + Assert len(g:_pm_perf_cmds) <= 6, + \ 'collect_status_local made ' . len(g:_pm_perf_cmds) + \ . ' git calls: ' . join(g:_pm_perf_cmds, ' | ') From de7fb6311d77a0149d9f0d2c4282240bdf876824 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:41:46 +0200 Subject: [PATCH 02/10] perf(git): batch collect_status_local to <= 7 subprocesses per module collect_status_local runs once per module inside status/update/check loops. Two cuts: - the per-module `git config -f .gitmodules` branch read now comes from the parse_modules cache (mtime-cached); the direct read is kept only when parse_modules is unavailable (vim_dir without .git) so hostile branch values still reach sanitize_branch (#9 parity) - the sequential upstream/show-ref cascade (rev-parse @{upstream}, show-ref origin/main, origin/master, rev-parse origin/HEAD, show-ref origin/) collapses into one for-each-ref call with the same selection order; its sha listing also replaces the remote rev-parse for origin/* refs (verbatim .gitmodules branches keep their rev-parse - they resolve LOCAL refs, v2.2.10 behavior) Worst case per module: 9 subprocesses -> 7 (6 with .gitmodules branch). tests/perf.vader pins the budget and the returned status dict. --- autoload/plugin_manager/git.vim | 136 +++++++++++++++++++++++--------- tests/perf.vader | 3 + 2 files changed, 100 insertions(+), 39 deletions(-) diff --git a/autoload/plugin_manager/git.vim b/autoload/plugin_manager/git.vim index c08bd86..70ece2a 100644 --- a/autoload/plugin_manager/git.vim +++ b/autoload/plugin_manager/git.vim @@ -387,14 +387,21 @@ function! plugin_manager#git#collect_status_local(module_path) abort " First try to find remote branch from .gitmodules at the vim config root. " Use the relative path as the submodule section key (not just the basename). - let l:vim_dir = plugin_manager#core#util#get_config('vim_dir', '') - let l:gitmodules_path = l:vim_dir . '/.gitmodules' + " Read from the parse_modules cache (mtime-cached): no per-module git + " subprocess for a value the cached parse already carries. let l:rel_path = plugin_manager#core#util#make_relative_path(a:module_path) - let l:res = plugin_manager#git#execute( - \ 'git config -f ' . shellescape(l:gitmodules_path) . - \ ' submodule.' . shellescape(l:rel_path) . '.branch', - \ '', 0, 0) - let l:remote_branch = l:res.success ? substitute(l:res.output, '\n', '', 'g') : '' + let l:modules = plugin_manager#git#parse_modules() + let l:remote_branch = get(get(l:modules, l:rel_path, {}), 'branch', '') + if empty(l:remote_branch) && empty(l:modules) + " parse_modules is unavailable (vim_dir is not a git repo): keep the + " direct .gitmodules read so untrusted branch values still reach + " sanitize_branch (which warns, see issue #9). + let l:res = plugin_manager#git#execute( + \ 'git config -f ' . shellescape(plugin_manager#core#util#get_config('vim_dir', '') . '/.gitmodules') . + \ ' submodule.' . shellescape(l:rel_path) . '.branch', + \ '', 0, 0) + let l:remote_branch = l:res.success ? substitute(l:res.output, '\n', '', 'g') : '' + endif " .gitmodules is user-writable config content: its branch value is passed " to `git pull origin ` - a leading dash would be parsed as a git " option (proven RCE via --upload-pack, issue #9). Refuse + fall back to @@ -416,40 +423,93 @@ function! plugin_manager#git#collect_status_local(module_path) abort endif endif - " If still not found, try to determine from standard branches + " If still not found, resolve from the remote-tracking refs in ONE batched + " call. Replaces the sequential show-ref origin/main / origin/master / + " rev-parse origin/HEAD / show-ref origin/ cascade (up to 4 + " subprocesses). Selection order kept: main > master > origin/HEAD + " (via its symref target, e.g. origin/develop) > origin/. + let l:remote_sha = '' if empty(l:remote_branch) - " Check if origin/main exists - let l:res = plugin_manager#git#execute('git show-ref --verify --quiet refs/remotes/origin/main', + " The format goes through the shell: escape it (parens are shell + " metacharacters, see tests/perf.vader for the regression pin). + let l:ref_fmt = shellescape('%(refname:short)' . "\t" . '%(symref:short)' . "\t" . '%(objectname)') + let l:res = plugin_manager#git#execute( + \ 'git for-each-ref --format=' . l:ref_fmt . ' refs/remotes/origin', \ a:module_path, 0, 0) + let l:remote_sha = '' if l:res.success - let l:remote_branch = 'origin/main' - else - " Check if origin/master exists - let l:res = plugin_manager#git#execute('git show-ref --verify --quiet refs/remotes/origin/master', - \ a:module_path, 0, 0) - if l:res.success - let l:remote_branch = 'origin/master' + let l:refs = [] + for l:line in split(l:res.output, '\n') + if empty(l:line) + continue + endif + let l:parts = split(l:line, "\t", 1) + if len(l:parts) < 3 + continue + endif + call add(l:refs, {'name': l:parts[0], 'symref': l:parts[1], 'sha': l:parts[2]}) + endfor + " Priority 1: origin/main + for l:ref in l:refs + if l:ref.name ==# 'origin/main' + let l:remote_branch = 'origin/main' + let l:remote_sha = l:ref.sha + break + endif + endfor + " Priority 2: origin/master + if empty(l:remote_branch) + for l:ref in l:refs + if l:ref.name ==# 'origin/master' + let l:remote_branch = 'origin/master' + let l:remote_sha = l:ref.sha + break + endif + endfor + endif + " Priority 3: the remote's default HEAD branch (symref target) + " (old cascade order: origin/HEAD beat origin/) + if empty(l:remote_branch) + for l:ref in l:refs + if !empty(l:ref.symref) + let l:remote_branch = l:ref.symref + let l:remote_sha = l:ref.sha + break + endif + endfor + endif + " Priority 4: origin/ + if empty(l:remote_branch) && l:result.branch !=# 'detached' && !empty(l:result.branch) + let l:candidate = 'origin/' . l:result.branch + for l:ref in l:refs + if l:ref.name ==# l:candidate + let l:remote_branch = l:candidate + let l:remote_sha = l:ref.sha + break + endif + endfor + endif + " An upstream-derived branch (origin/ from @{upstream}) also has + " its sha in this listing - same value `git rev-parse` would return. + if empty(l:remote_sha) && !empty(l:remote_branch) + for l:ref in l:refs + if l:ref.name ==# l:remote_branch + let l:remote_sha = l:ref.sha + break + endif + endfor endif - endif - endif - - " Ask the remote for its default HEAD branch - if empty(l:remote_branch) - let l:res = plugin_manager#git#execute( - \ 'git rev-parse --abbrev-ref origin/HEAD', a:module_path, 0, 0) - if l:res.success - let l:remote_branch = substitute(l:res.output, '\n', '', 'g') endif endif - " Last resort: try origin/ when all else fails - if empty(l:remote_branch) && l:result.branch !=# 'detached' && !empty(l:result.branch) - let l:candidate = 'origin/' . l:result.branch - let l:res = plugin_manager#git#execute( - \ 'git show-ref --verify --quiet refs/remotes/' . l:candidate, + " Resolve the remote commit sha when the listing did not provide it. + " A .gitmodules branch used verbatim (e.g. 'main', a LOCAL ref) is not + " an origin/* ref: it keeps its own rev-parse (v2.2.10 behavior). + if !empty(l:remote_branch) && empty(l:remote_sha) + let l:res = plugin_manager#git#execute('git rev-parse ' . shellescape(l:remote_branch), \ a:module_path, 0, 0) if l:res.success - let l:remote_branch = l:candidate + let l:remote_sha = substitute(l:res.output, '\n', '', 'g') endif endif @@ -458,13 +518,11 @@ function! plugin_manager#git#collect_status_local(module_path) abort let l:result.remote_branch = l:remote_branch - " Get the latest commit on the remote branch (skip if branch unknown) - if !empty(l:remote_branch) - let l:res = plugin_manager#git#execute('git rev-parse ' . shellescape(l:result.remote_branch), - \ a:module_path, 0, 0) - if l:res.success - let l:result.remote_commit = substitute(l:res.output, '\n', '', 'g') - endif + " Get the latest commit on the remote branch (skip if branch unknown). + " The sha is usually already known from the for-each-ref listing; a + " verbatim .gitmodules branch keeps its own rev-parse above. + if !empty(l:remote_branch) && !empty(l:remote_sha) + let l:result.remote_commit = l:remote_sha endif " Direct check if remote commit is different from current commit diff --git a/tests/perf.vader b/tests/perf.vader index 6fb839d..abd50f2 100644 --- a/tests/perf.vader +++ b/tests/perf.vader @@ -24,6 +24,9 @@ Before: unlet! g:plugin_manager_debug_mode call delete('/tmp/pm-perf-test', 'rf') call mkdir('/tmp/pm-perf-test/vim/pack/plugins/start', 'p') + " Real usage: the vim config dir is a git repository (parse_modules + " requires it); init it so .gitmodules comes from the cached parse. + call system('git init -q ' . shellescape('/tmp/pm-perf-test/vim')) " Module repo on branch main with a remote-tracking origin/main and no " upstream (worst-case remote-branch resolution: full cascade). From 4daa57f537eca6ecc0dd66007b3d7fda905d31c6 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:49:15 +0200 Subject: [PATCH 03/10] refactor: remove dead code paths and honor opts.force Tier B dead-code sweep, zero behavior change except where noted: - async: drop never-read job state fields ('id', 'started', 'queued') and a dead local (l:opts) - plugin: drop g:plugin_manager_periodic_timer (written, never read) - syntax: drop highlight keywords no code emits (Synced, Skipped, timed out, Stashing changes); 'Pending' stays (update.vim uses it) - ui: drop the 'pending' glyph key, the legacy numeric status branch of get_status_glyph (all callers/tests pass keywords now), the unreachable replace path of update_sidebar and the list branch of log_detail - delete ftdetect/pluginmanager.vim (only matched a literal file named PluginManager; the sidebar sets its own filetype) and the Makefile.test forwarding shim - check: opts.force is now real (was documented, never read): force fetches and leaves the cache untouched so the next startup check re-fetches (tests/check.vader pins the contract) --- CONTRIBUTING.md | 1 - autoload/plugin_manager/async.vim | 5 ---- autoload/plugin_manager/cmd/check.vim | 13 +++++++--- autoload/plugin_manager/ui.vim | 30 +++++++---------------- plugin/plugin_manager.vim | 4 +--- syntax/pluginmanager.vim | 8 +++---- tests/check.vader | 11 +++++++++ tests/ui.vader | 34 +++++---------------------- 8 files changed, 40 insertions(+), 66 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab60da8..183ab6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -547,7 +547,6 @@ Understanding the project's complete structure will help you contribute effectiv ├── CONTRIBUTING.md # Contribution guidelines (this file) ├── LICENSE # MIT license ├── Makefile # Build, test, and version management -├── Makefile.test # Compatibility shim (delegates to Makefile) └── README.md # Project overview and usage ``` diff --git a/autoload/plugin_manager/async.vim b/autoload/plugin_manager/async.vim index f99dfaa..bd55f49 100644 --- a/autoload/plugin_manager/async.vim +++ b/autoload/plugin_manager/async.vim @@ -69,15 +69,12 @@ function! plugin_manager#async#start_job(cmd, opts) abort " Promote opts.callback to the top-level 'callback' key so that " s:process_job_completion can find it without having to dig into opts. let s:jobs[l:job_id] = { - \ 'id': l:job_id, \ 'cmd': a:cmd, \ 'opts': a:opts, \ 'output': '', \ 'errors': '', \ 'status': -1, - \ 'started': 0, \ 'finished': 0, - \ 'queued': localtime(), \ 'job': v:null, \ 'timeout_timer': 0, \ 'exited': 0, @@ -105,8 +102,6 @@ function! s:spawn_job(job_id) abort endif let l:job = s:jobs[a:job_id] - let l:opts = l:job.opts - let l:job.started = localtime() let s:active_count += 1 diff --git a/autoload/plugin_manager/cmd/check.vim b/autoload/plugin_manager/cmd/check.vim index 3dbf76c..8a9917a 100644 --- a/autoload/plugin_manager/cmd/check.vim +++ b/autoload/plugin_manager/cmd/check.vim @@ -9,7 +9,8 @@ " @param opts: dict with optional keys: " - 'silent' : 1 to suppress the sidebar header/progress (background mode) " - 'on_done' : Funcref called with the list of plugins behind once finished -" - 'force' : 1 to ignore the cache freshness (always fetch) +" - 'force' : 1 to always fetch and leave the update-check cache +" untouched (the next startup check re-fetches) function! plugin_manager#cmd#check#execute(...) abort try call plugin_manager#core#util#require_vim_directory('check') @@ -212,8 +213,14 @@ function! s:finalize(ctx) abort endfunction function! s:finish(plugins, opts) abort - " Persist to cache so startup checks can skip the network next time - call plugin_manager#core#cache#write(a:plugins) + " Persist to cache so startup checks can skip the network next time - + " unless forced: opts.force means "always fetch and leave the cache + " untouched" (the next startup check re-fetches, TTL skipped). + if !get(a:opts, 'force', 0) + call plugin_manager#core#cache#write(a:plugins) + else + call delete(plugin_manager#core#cache#get_path()) + endif " Notify caller (e.g. auto-update flow) if has_key(a:opts, 'on_done') && !empty(a:opts.on_done) diff --git a/autoload/plugin_manager/ui.vim b/autoload/plugin_manager/ui.vim index 22320ae..0e6c1fa 100644 --- a/autoload/plugin_manager/ui.vim +++ b/autoload/plugin_manager/ui.vim @@ -7,7 +7,7 @@ let s:fancy_ui = get(g:, 'plugin_manager_fancy_ui', 1) && s:unicode_support let s:has_timers = exists('*timer_start') && exists('*timer_stop') " Status-to-glyph mapping (single source of truth used by complete_operation -" and status.vim). Keys: 'ok', 'fail', 'warn', 'info', 'skip', 'pending'. +" and status.vim). Keys: 'ok', 'fail', 'warn', 'info', 'skip'. " Built lazily after s:symbols so the references resolve correctly. " (Populated at the bottom of this section, after s:symbols is defined.) let s:status_glyphs = {} @@ -21,7 +21,6 @@ let s:symbols = { \ 'separator': s:fancy_ui ? '━' : '-', \ 'warning': s:fancy_ui ? '⚠' : '!', \ 'info': s:fancy_ui ? 'ℹ' : 'i', - \ 'pending': s:fancy_ui ? '○' : 'o', \ } " Build a separator bar sized to the given title (display width, multibyte @@ -46,7 +45,6 @@ let s:status_glyphs = { \ 'warn': s:symbols.warning, \ 'info': s:symbols.info, \ 'skip': s:symbols.info, - \ 'pending': s:symbols.pending, \ } let s:active_spinner_style = get(g:, 'plugin_manager_spinner_style', 'dots') @@ -177,10 +175,9 @@ function! plugin_manager#ui#update_sidebar(lines, append) abort return endif - if a:append && !empty(a:lines) + " All callers append (v2.2.10 audit): the replace path was unreachable. + if !empty(a:lines) call s:append_lines(l:buf, a:lines) - elseif !a:append - call s:replace_all(l:buf, empty(a:lines) ? [''] : a:lines) endif call s:redraw_if_visible() @@ -239,13 +236,9 @@ function! plugin_manager#ui#update_operation(op_id, status_text) abort call s:redraw_if_visible() endfunction -" Resolve a status value to a glyph. -" Accepts a keyword ('ok','fail','warn','info','skip','pending') or a legacy -" boolean/number (non-zero -> 'ok', zero -> 'fail'). +" Resolve a status value to a glyph. Accepts a keyword +" ('ok','fail','warn','info','skip'). function! plugin_manager#ui#get_status_glyph(status) abort - if type(a:status) == v:t_number - return a:status ? s:symbols.tick : s:symbols.cross - endif if has_key(s:status_glyphs, a:status) return s:status_glyphs[a:status] endif @@ -253,9 +246,7 @@ function! plugin_manager#ui#get_status_glyph(status) abort return s:symbols.info endfunction -" Complete an operation. status can be: -" - a boolean/number (legacy): non-zero -> ok (tick), zero -> fail (cross) -" - a keyword string: 'ok','fail','warn','info','skip','pending' +" Complete an operation. status is a keyword: 'ok','fail','warn','info','skip'. function! plugin_manager#ui#complete_operation(op_id, status, final_message) abort let l:symbol = plugin_manager#ui#get_status_glyph(a:status) call plugin_manager#ui#complete_operation_symbol(a:op_id, l:symbol, a:final_message) @@ -290,15 +281,10 @@ endfunction " debug_mode) or 'warn' (failure details, always written - issue #5). function! plugin_manager#ui#log_detail(component, detail, ...) abort let l:level = a:0 >= 1 && a:1 ==# 'warn' ? 'warn' : 'debug' - if type(a:detail) == v:t_list - let l:detail = join(a:detail, "\n") - else - let l:detail = a:detail - endif if l:level ==# 'warn' - call plugin_manager#core#log#warn(a:component, l:detail) + call plugin_manager#core#log#warn(a:component, a:detail) else - call plugin_manager#core#log#debug(a:component, l:detail) + call plugin_manager#core#log#debug(a:component, a:detail) endif endfunction diff --git a/plugin/plugin_manager.vim b/plugin/plugin_manager.vim index abb80fc..a858878 100644 --- a/plugin/plugin_manager.vim +++ b/plugin/plugin_manager.vim @@ -186,9 +186,7 @@ if g:plugin_manager_check_on_startup " Periodic re-check using the configured interval (hours -> milliseconds). " The check itself still honors the cache, so this only fetches when due. if exists('*timer_start') && get(g:, 'plugin_manager_check_interval', 24) > 0 - let s:pm_check_period_ms = g:plugin_manager_check_interval * 3600 * 1000 - let g:plugin_manager_periodic_timer = - \ timer_start(s:pm_check_period_ms, + call timer_start(g:plugin_manager_check_interval * 3600 * 1000, \ {-> plugin_manager#cmd#check#startup()}, {'repeat': -1}) endif endif diff --git a/syntax/pluginmanager.vim b/syntax/pluginmanager.vim index f9284c1..d2c8ba6 100644 --- a/syntax/pluginmanager.vim +++ b/syntax/pluginmanager.vim @@ -39,11 +39,11 @@ syntax match PMSymbolArrow /^[→>][-> ] / contains=NONE syntax match PMSymbolBullet /^\s*[•*] / contains=NONE " Status text keywords that appear after the dots -syntax match PMStatusOk /Up-to-date\|Installed\|Initialized\|Restored\|Synced\|Updated\|Committed\|Pushed\|Copied\|Reloaded\|Helptags generated\|Added/ -syntax match PMStatusSkip /On custom branch\|Already exists\|No changes\|No doc directory\|Skipped/ -syntax match PMStatusWarn /\\|commits behind\|No remotes\|Source not found\|timed out/ +syntax match PMStatusOk /Up-to-date\|Installed\|Initialized\|Restored\|Updated\|Committed\|Pushed\|Copied\|Reloaded\|Helptags generated\|Added/ +syntax match PMStatusSkip /On custom branch\|Already exists\|No changes\|No doc directory/ +syntax match PMStatusWarn /\\|commits behind\|No remotes\|Source not found/ syntax match PMStatusFail /\\|Update failed\|Push failed\|Commit failed\|Exec failed\|Installation failed\|Invalid URL format/ -syntax match PMStatusProgress /Installing\|Removing\|Updating\|Checking\|Fetching updates\|Stashing changes\|Pulling changes\|Analyzing\|Generating helptags\|Backing up\|Committing\|Pushing\|Pending\|Reloading\|Processing\|Adding/ +syntax match PMStatusProgress /Installing\|Removing\|Updating\|Checking\|Fetching updates\|Pulling changes\|Analyzing\|Generating helptags\|Backing up\|Committing\|Pushing\|Pending\|Reloading\|Processing\|Adding/ " Dots separating name from status (the padding in format_plugin_line) syntax match PMDots /\.\{2,}/ diff --git a/tests/check.vader b/tests/check.vader index 94eb5c0..e06922c 100644 --- a/tests/check.vader +++ b/tests/check.vader @@ -61,6 +61,17 @@ Execute (check silent mode still writes the update-check cache): Assert has_key(c_after, 'timestamp'), 'cache should carry a timestamp' Assert has_key(c_after, 'plugins'), 'cache should carry a plugins list' +Execute (check force=1 always fetches and leaves the cache untouched): + " Doc contract (opts.force): ignore the cache freshness, always fetch, + " and do not persist the result - so the next startup check re-fetches + " (the TTL is effectively skipped). + call plugin_manager#cmd#check#execute({'silent': 1}) + let c_before = plugin_manager#core#cache#read() + Assert !empty(c_before), 'cache populated by the first check' + call plugin_manager#cmd#check#execute({'silent': 1, 'force': 1}) + let c_after = plugin_manager#core#cache#read() + Assert empty(c_after), 'force must invalidate the cache, got: ' . string(c_after) + Execute (a failed fetch is reported, never faked as up-to-date): " Regression: '2>/dev/null || true' masked fetch failures and the stale " ref comparison then reported a fake Up-to-date. diff --git a/tests/ui.vader b/tests/ui.vader index d35ed7c..7ceb232 100644 --- a/tests/ui.vader +++ b/tests/ui.vader @@ -22,7 +22,7 @@ Execute (operations do not steal focus from the user window): let before = getpos('.') let opid = plugin_manager#ui#start_operation('myplugin', 'Installing') call plugin_manager#ui#update_operation(opid, 'Cloning') - call plugin_manager#ui#complete_operation(opid, 1, 'Done') + call plugin_manager#ui#complete_operation(opid, 'ok', 'Done') let after = getpos('.') AssertEqual before[1], after[1], 'cursor line should not move' AssertEqual before[2], after[2], 'cursor column should not move' @@ -32,7 +32,7 @@ Execute (completed operation is rendered in the sidebar buffer): call plugin_manager#ui#open_sidebar(['Header']) wincmd p let opid = plugin_manager#ui#start_operation('alpha', 'Installing') - call plugin_manager#ui#complete_operation(opid, 1, 'Installed') + call plugin_manager#ui#complete_operation(opid, 'ok', 'Installed') let sbuf = bufnr('PluginManager') let content = join(getbufline(sbuf, 1, '$'), "\n") Assert content =~# 'alpha', 'sidebar should contain the plugin name' @@ -154,11 +154,9 @@ Execute (get_status_glyph maps keyword to glyph): AssertEqual info, plugin_manager#ui#get_status_glyph('info'), 'info -> info' AssertEqual info, plugin_manager#ui#get_status_glyph('skip'), 'skip -> info' -Execute (get_status_glyph accepts legacy boolean - non-zero is ok): - let tick = plugin_manager#ui#get_symbol('tick') - let cross = plugin_manager#ui#get_symbol('cross') - AssertEqual tick, plugin_manager#ui#get_status_glyph(1), 'non-zero bool -> tick' - AssertEqual cross, plugin_manager#ui#get_status_glyph(0), 'zero bool -> cross' +Execute (get_status_glyph falls back to info for unknown keys): + let info = plugin_manager#ui#get_symbol('info') + AssertEqual info, plugin_manager#ui#get_status_glyph('bogus'), 'unknown -> info' Execute (complete_operation with keyword renders correct glyph): call plugin_manager#ui#open_sidebar(['Header']) @@ -172,32 +170,12 @@ Execute (complete_operation with keyword renders correct glyph): Assert content =~# 'Modified', 'status text should appear' Assert content =~# warn_glyph, 'warning glyph should appear for warn status' -Execute (complete_operation with legacy boolean 1 renders tick): - call plugin_manager#ui#open_sidebar(['Header']) - wincmd p - let opid = plugin_manager#ui#start_operation('plugin2', 'Installing') - call plugin_manager#ui#complete_operation(opid, 1, 'Installed') - let sbuf = bufnr('PluginManager') - let content = join(getbufline(sbuf, 1, '$'), "\n") - let tick = plugin_manager#ui#get_symbol('tick') - Assert content =~# tick, 'legacy boolean 1 should render tick glyph' - -Execute (complete_operation with legacy boolean 0 renders cross): - call plugin_manager#ui#open_sidebar(['Header']) - wincmd p - let opid = plugin_manager#ui#start_operation('plugin3', 'Installing') - call plugin_manager#ui#complete_operation(opid, 0, 'Failed') - let sbuf = bufnr('PluginManager') - let content = join(getbufline(sbuf, 1, '$'), "\n") - let cross = plugin_manager#ui#get_symbol('cross') - Assert content =~# cross, 'legacy boolean 0 should render cross glyph' - Execute (purge stale operations does not remove active ops): call plugin_manager#ui#open_sidebar(['Header']) wincmd p let opid = plugin_manager#ui#start_operation('keep', 'Running') call plugin_manager#ui#_purge_stale_test() - call plugin_manager#ui#complete_operation(opid, 1, 'Kept') + call plugin_manager#ui#complete_operation(opid, 'ok', 'Kept') let sbuf = bufnr('PluginManager') let content = join(getbufline(sbuf, 1, '$'), "\n") Assert content =~# 'keep', 'active operation should survive purge' From 5ab6724590b212dc6eabb31ba76040ba852297a8 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:51:59 +0200 Subject: [PATCH 04/10] refactor(health): data-driven checks (one Funcref per check) health#execute was a single 200-line function of sequential if/else blocks. Checks are now a table of Funcrefs (s:checks) returning [status, label, detail] items; three custom checks (git version, log probe, submodule scan) moved to named helpers. Report order, messages and the both-problems submodule case are unchanged - pinned first by a characterization test (tests/health.vader: exact statuses on a healthy fixture). --- autoload/plugin_manager/cmd/health.vim | 302 +++++++++++-------------- tests/health.vader | 40 ++++ 2 files changed, 178 insertions(+), 164 deletions(-) diff --git a/autoload/plugin_manager/cmd/health.vim b/autoload/plugin_manager/cmd/health.vim index a0e75fc..55c68ab 100644 --- a/autoload/plugin_manager/cmd/health.vim +++ b/autoload/plugin_manager/cmd/health.vim @@ -4,6 +4,11 @@ " Run a set of read-only precondition checks and report results in the sidebar. " Each check renders one line with an ok/warn/fail glyph. A failed check is a " reported line, not a thrown exception; the function never aborts mid-run. +" +" Structure: s:checks() returns one Funcref per check. Each Funcref takes the +" vim dir and returns a list of [status, label, detail] items (status +" 'ok'|'warn'|'fail'); an empty list means the check is not applicable. + function! plugin_manager#cmd#health#execute() abort try call plugin_manager#ui#open_header('Health check:') @@ -13,176 +18,20 @@ function! plugin_manager#cmd#health#execute() abort let l:warn = 0 let l:fail = 0 - " ------------------------------------------------------------------ - " 1. Git executable present - " ------------------------------------------------------------------ - if executable('git') - call s:report('ok', 'git executable', 'found') - let l:ok += 1 - else - call s:report('fail', 'git executable', 'not found in PATH') - let l:fail += 1 - endif - - " ------------------------------------------------------------------ - " 2. Git version (minimum: 2.39, set by Debian Bookworm). - " The codebase uses no feature newer than git 1.9 in practice; - " 2.39 is chosen to match the oldest fully-supported distribution - " in the CI matrix (Debian Bookworm ships 2.39.2). - " ------------------------------------------------------------------ - if executable('git') - " Route through git#execute like every other git invocation - let l:git_ver_out = plugin_manager#git#execute('git --version', '', 0, 0).output - " Output is 'git version X.Y.Z' - let l:git_ver_parts = matchlist(l:git_ver_out, - \ 'git version \(\d\+\)\.\(\d\+\)') - if !empty(l:git_ver_parts) - let l:git_major = str2nr(l:git_ver_parts[1]) - let l:git_minor = str2nr(l:git_ver_parts[2]) - let l:git_ver_str = l:git_ver_parts[1] . '.' . l:git_ver_parts[2] - if l:git_major > 2 || (l:git_major == 2 && l:git_minor >= 39) - call s:report('ok', 'git version', l:git_ver_str . ' (>= 2.39)') + for l:Check in s:checks() + for l:result in l:Check(l:vim_dir) + call s:report(l:result[0], l:result[1], l:result[2]) + if l:result[0] ==# 'ok' let l:ok += 1 - else - call s:report('warn', 'git version', - \ l:git_ver_str . ' (< 2.39 documented minimum)') + elseif l:result[0] ==# 'warn' let l:warn += 1 - endif - else - call s:report('warn', 'git version', 'could not parse: ' . trim(l:git_ver_out)) - let l:warn += 1 - endif - endif - - " ------------------------------------------------------------------ - " 3. Async support (+job and +channel) - " ------------------------------------------------------------------ - if has('job') && has('channel') - call s:report('ok', 'async support', '+job +channel available') - let l:ok += 1 - else - call s:report('warn', 'async support', - \ '+job or +channel missing - operations will run synchronously') - let l:warn += 1 - endif - - " ------------------------------------------------------------------ - " 4. Vim version >= 8.2 - " ------------------------------------------------------------------ - if v:version >= 802 - call s:report('ok', 'Vim version', - \ 'v' . (v:version / 100) . '.' . (v:version % 100) . ' (>= 8.2)') - let l:ok += 1 - else - call s:report('fail', 'Vim version', - \ 'v' . (v:version / 100) . '.' . (v:version % 100) . ' (< 8.2 required)') - let l:fail += 1 - endif - - " ------------------------------------------------------------------ - " 5. Encoding UTF-8 - " ------------------------------------------------------------------ - if &encoding ==# 'utf-8' - call s:report('ok', 'encoding', 'utf-8') - let l:ok += 1 - else - call s:report('warn', 'encoding', - \ &encoding . ' (utf-8 recommended for fancy UI)') - let l:warn += 1 - endif - - " ------------------------------------------------------------------ - " 6. Vim directory is a git repository - " ------------------------------------------------------------------ - if !empty(l:vim_dir) && isdirectory(l:vim_dir . '/.git') - call s:report('ok', 'vim dir is git repo', l:vim_dir) - let l:ok += 1 - elseif empty(l:vim_dir) - call s:report('fail', 'vim dir', 'g:plugin_manager_vim_dir not set') - let l:fail += 1 - else - call s:report('fail', 'vim dir is git repo', - \ l:vim_dir . '/.git not found') - let l:fail += 1 - endif - - " ------------------------------------------------------------------ - " 7. Log directory writable - " ------------------------------------------------------------------ - if !empty(l:vim_dir) - let l:log_dir = l:vim_dir . '/logs' - let l:probe = l:log_dir . '/.pm_health_probe' - if !isdirectory(l:log_dir) - call mkdir(l:log_dir, 'p') - endif - if writefile([], l:probe) == 0 - call delete(l:probe) - call s:report('ok', 'log dir writable', l:log_dir) - let l:ok += 1 - else - call s:report('fail', 'log dir writable', - \ 'cannot write to ' . l:log_dir) - let l:fail += 1 - endif - endif - - " ------------------------------------------------------------------ - " 8. Submodules initialized (no '-' prefix in git submodule status) - " ------------------------------------------------------------------ - if !empty(l:vim_dir) && isdirectory(l:vim_dir . '/.git') - let l:sub_res = plugin_manager#git#execute( - \ 'git submodule status', l:vim_dir, 0, 0) - if l:sub_res.success - let l:uninit = [] - let l:outofsync = [] - for l:sline in split(l:sub_res.output, "\n") - if l:sline =~# '^-' - call add(l:uninit, substitute(l:sline, '^-\S\+ \(\S\+\).*$', '\1', '')) - elseif l:sline =~# '^+' - call add(l:outofsync, substitute(l:sline, '^+\S\+ \(\S\+\).*$', '\1', '')) - endif - endfor - if empty(l:uninit) && empty(l:outofsync) - call s:report('ok', 'submodules', 'all initialized and in sync') - let l:ok += 1 else - if !empty(l:uninit) - call s:report('fail', 'submodules uninitialized', - \ join(l:uninit, ', ')) - let l:fail += 1 - endif - if !empty(l:outofsync) - call s:report('warn', 'submodules out of sync', - \ join(l:outofsync, ', ')) - let l:warn += 1 - endif + let l:fail += 1 endif - else - call s:report('warn', 'submodules', 'git submodule status failed') - let l:warn += 1 - endif - endif - - " ------------------------------------------------------------------ - " 9. Remotes configured - " ------------------------------------------------------------------ - if !empty(l:vim_dir) && isdirectory(l:vim_dir . '/.git') - let l:rmt_res = plugin_manager#git#execute( - \ 'git remote', l:vim_dir, 0, 0) - let l:remotes = filter(split(l:rmt_res.output, "\n"), '!empty(v:val)') - if !empty(l:remotes) - call s:report('ok', 'remotes', join(l:remotes, ', ')) - let l:ok += 1 - else - call s:report('warn', 'remotes', - \ 'no remotes configured (backup/push will fail)') - let l:warn += 1 - endif - endif + endfor + endfor - " ------------------------------------------------------------------ " Footer summary - " ------------------------------------------------------------------ let l:total = l:ok + l:warn + l:fail let l:summary = l:ok . '/' . l:total . ' checks passed' if l:warn > 0 @@ -205,6 +54,131 @@ function! plugin_manager#cmd#health#execute() abort endtry endfunction +" ------------------------------------------------------------------------------ +" CHECKS +" ------------------------------------------------------------------------------ + +" One Funcref per check, in report order. Each takes the vim dir and +" returns a list of [status, label, detail] items ([] = not applicable). +function! s:checks() abort + return [ + \ {-> executable('git') + \ ? [['ok', 'git executable', 'found']] + \ : [['fail', 'git executable', 'not found in PATH']]}, + \ function('s:check_git_version'), + \ {-> (has('job') && has('channel')) + \ ? [['ok', 'async support', '+job +channel available']] + \ : [['warn', 'async support', + \ '+job or +channel missing - operations will run synchronously']]}, + \ {-> v:version >= 802 + \ ? [['ok', 'Vim version', + \ 'v' . (v:version / 100) . '.' . (v:version % 100) . ' (>= 8.2)']] + \ : [['fail', 'Vim version', + \ 'v' . (v:version / 100) . '.' . (v:version % 100) . ' (< 8.2 required)']]}, + \ {-> &encoding ==# 'utf-8' + \ ? [['ok', 'encoding', 'utf-8']] + \ : [['warn', 'encoding', + \ &encoding . ' (utf-8 recommended for fancy UI)']]}, + \ {vim_dir -> empty(vim_dir) + \ ? [['fail', 'vim dir', 'g:plugin_manager_vim_dir not set']] + \ : (isdirectory(vim_dir . '/.git') + \ ? [['ok', 'vim dir is git repo', vim_dir]] + \ : [['fail', 'vim dir is git repo', vim_dir . '/.git not found']])}, + \ function('s:check_log_dir'), + \ function('s:check_submodules'), + \ {vim_dir -> empty(vim_dir) || !isdirectory(vim_dir . '/.git') + \ ? [] + \ : (s:remote_names(vim_dir)->empty() + \ ? [['warn', 'remotes', 'no remotes configured (backup/push will fail)']] + \ : [['ok', 'remotes', s:remote_names(vim_dir)->join(', ')]])}, + \ ] +endfunction + +" Git version (minimum: 2.39, set by Debian Bookworm). +" The codebase uses no feature newer than git 1.9 in practice; +" 2.39 is chosen to match the oldest fully-supported distribution +" in the CI matrix (Debian Bookworm ships 2.39.2). +" Skipped entirely when git is not executable (check 1 already failed). +function! s:check_git_version(_) abort + if !executable('git') + return [] + endif + " Route through git#execute like every other git invocation + let l:git_ver_out = plugin_manager#git#execute('git --version', '', 0, 0).output + " Output is 'git version X.Y.Z' + let l:git_ver_parts = matchlist(l:git_ver_out, + \ 'git version \(\d\+\)\.\(\d\+\)') + if empty(l:git_ver_parts) + return [['warn', 'git version', 'could not parse: ' . trim(l:git_ver_out)]] + endif + let l:git_major = str2nr(l:git_ver_parts[1]) + let l:git_minor = str2nr(l:git_ver_parts[2]) + let l:git_ver_str = l:git_ver_parts[1] . '.' . l:git_ver_parts[2] + if l:git_major > 2 || (l:git_major == 2 && l:git_minor >= 39) + return [['ok', 'git version', l:git_ver_str . ' (>= 2.39)']] + endif + return [['warn', 'git version', + \ l:git_ver_str . ' (< 2.39 documented minimum)']] +endfunction + +" Log directory writable (probed with a throwaway file). Not applicable +" when the vim dir is not set (check 6 already reported that). +function! s:check_log_dir(vim_dir) abort + if empty(a:vim_dir) + return [] + endif + let l:log_dir = a:vim_dir . '/logs' + let l:probe = l:log_dir . '/.pm_health_probe' + if !isdirectory(l:log_dir) + call mkdir(l:log_dir, 'p') + endif + if writefile([], l:probe) == 0 + call delete(l:probe) + return [['ok', 'log dir writable', l:log_dir]] + endif + return [['fail', 'log dir writable', 'cannot write to ' . l:log_dir]] +endfunction + +" Submodules initialized (no '-' prefix in git submodule status) and in +" sync (no '+' prefix). Can report BOTH problems in one pass. Not +" applicable outside a git repo. +function! s:check_submodules(vim_dir) abort + if empty(a:vim_dir) || !isdirectory(a:vim_dir . '/.git') + return [] + endif + let l:sub_res = plugin_manager#git#execute( + \ 'git submodule status', a:vim_dir, 0, 0) + if !l:sub_res.success + return [['warn', 'submodules', 'git submodule status failed']] + endif + let l:uninit = [] + let l:outofsync = [] + for l:sline in split(l:sub_res.output, "\n") + if l:sline =~# '^-' + call add(l:uninit, substitute(l:sline, '^-\S\+ \(\S\+\).*$', '\1', '')) + elseif l:sline =~# '^+' + call add(l:outofsync, substitute(l:sline, '^+\S\+ \(\S\+\).*$', '\1', '')) + endif + endfor + if empty(l:uninit) && empty(l:outofsync) + return [['ok', 'submodules', 'all initialized and in sync']] + endif + let l:results = [] + if !empty(l:uninit) + call add(l:results, ['fail', 'submodules uninitialized', join(l:uninit, ', ')]) + endif + if !empty(l:outofsync) + call add(l:results, ['warn', 'submodules out of sync', join(l:outofsync, ', ')]) + endif + return l:results +endfunction + +" Configured git remotes of the vim dir (empty list when none). +function! s:remote_names(vim_dir) abort + let l:rmt_res = plugin_manager#git#execute('git remote', a:vim_dir, 0, 0) + return filter(split(l:rmt_res.output, "\n"), '!empty(v:val)') +endfunction + " ------------------------------------------------------------------------------ " PRIVATE HELPERS " ------------------------------------------------------------------------------ diff --git a/tests/health.vader b/tests/health.vader index 0ff73b8..cb78bf2 100644 --- a/tests/health.vader +++ b/tests/health.vader @@ -73,6 +73,46 @@ Execute (health sidebar contains expected check labels): \ 'sidebar should contain submodules check' unlet g:_pm_hlt_sbuf3 g:_pm_hlt_content +Execute (health check statuses on a healthy fixture are pinned): + " Characterization: each check's exact status on the healthy fixture + " (git repo, no remotes, no submodules). Condition-dependent checks + " (async/encoding/vim version) assert against the actual environment. + let g:_pm_hlt_stbuf = bufnr('PluginManager') + if g:_pm_hlt_stbuf != -1 + execute 'bwipeout! ' . g:_pm_hlt_stbuf + endif + call plugin_manager#cmd#health#execute() + let g:_pm_hlt_stbuf = bufnr('PluginManager') + let g:_pm_hlt_scontent = join(getbufline(g:_pm_hlt_stbuf, 1, '$'), "\n") + " Deterministic checks + Assert g:_pm_hlt_scontent =~# 'git executable.*found', + \ 'git executable should be ok, got: ' . g:_pm_hlt_scontent + Assert g:_pm_hlt_scontent =~# 'git version.*>= 2.39', + \ 'git version should report ok, got: ' . g:_pm_hlt_scontent + Assert g:_pm_hlt_scontent =~# 'vim dir is git repo: ' . g:_pm_hlt_vim, + \ 'vim dir repo check should be ok' + Assert g:_pm_hlt_scontent =~# 'log dir writable', + \ 'log dir check should run' + Assert g:_pm_hlt_scontent =~# 'submodules.*all initialized and in sync', + \ 'empty .gitmodules means submodules ok' + Assert g:_pm_hlt_scontent =~# 'no remotes configured', + \ 'fixture has no remotes: warn expected' + " Environment-dependent checks: status must match the environment + if has('job') && has('channel') + Assert g:_pm_hlt_scontent =~# 'async support.*+job +channel available' + else + Assert g:_pm_hlt_scontent =~# 'async support.*synchronously' + endif + if &encoding ==# 'utf-8' + Assert g:_pm_hlt_scontent =~# 'encoding.*utf-8' + endif + " Footer: summary line, never a failure on the healthy fixture + Assert g:_pm_hlt_scontent =~# 'checks passed', + \ 'footer summary expected, got: ' . g:_pm_hlt_scontent + Assert g:_pm_hlt_scontent !~# 'failure', + \ 'healthy fixture must not report failures' + unlet g:_pm_hlt_stbuf g:_pm_hlt_scontent + Execute (health fails gracefully when vim_dir has no .git): " Point vim_dir at a directory that exists but is not a git repo. let g:_pm_hlt_nogit = g:_pm_hlt_root . '/nogit' From d8e574e5ce04a9eb3e343f8999bec33a51dff020 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:54:34 +0200 Subject: [PATCH 05/10] refactor: single userinfo sanitizer, dedup log writers, drop backup re-read - sanitize_url/sanitize_cmd share one regex impl (s:sanitize_url_impl) - log debug/warn/trace share s:leveled; the doubled level prefix ('DEBUG:c:DEBUG:msg') collapses to a single level marker - log line format now '| comp | EXTERNAL | DEBUG:msg' (tests/log.vader pinned) - backup: drop a redundant get_config('vim_dir') re-read inside s:backup_vimrc_file - rejected as behavior-changing (audit over-flagged): declare footer dedup (sync/async messages differ), header+info extraction (zero net lines), add.vim catch dedup (marginal), inline helptags -> helper (install outcome on helptags failure would change) --- autoload/plugin_manager/cmd/backup.vim | 1 - autoload/plugin_manager/core/log.vim | 36 +++++++++++++++++--------- autoload/plugin_manager/core/util.vim | 10 +++++-- tests/log.vader | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/autoload/plugin_manager/cmd/backup.vim b/autoload/plugin_manager/cmd/backup.vim index e5683aa..4b9cf68 100644 --- a/autoload/plugin_manager/cmd/backup.vim +++ b/autoload/plugin_manager/cmd/backup.vim @@ -47,7 +47,6 @@ function! s:backup_vimrc_file() abort " Copy vimrc 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', '') 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', diff --git a/autoload/plugin_manager/core/log.vim b/autoload/plugin_manager/core/log.vim index 67df207..dbb96df 100644 --- a/autoload/plugin_manager/core/log.vim +++ b/autoload/plugin_manager/core/log.vim @@ -150,28 +150,40 @@ function! plugin_manager#core#log#view() abort endtry endfunction +" Common writer for the three leveled entries. a:level: 'debug'|'warn'|'trace'. +" Gates: debug requires debug_mode, warn/trace only enable_logging (a +" swallowed failure must never depend on debug_mode being on - issue #5). +function! s:leveled(level, component, message) abort + if a:level ==# 'debug' + \ && !(get(g:, 'plugin_manager_enable_logging', 1) + \ && get(g:, 'plugin_manager_debug_mode', 0)) + return + endif + if get(g:, 'plugin_manager_enable_logging', 1) + call plugin_manager#core#log#write({ + \ 'type': a:level ==# 'warn' ? 'internal' : 'external', + \ 'component': a:component, + \ 'code': toupper(a:level), + \ 'message': a:level ==# 'warn' + \ ? a:message + \ : toupper(a:level) . ':' . a:message, + \ }) + endif +endfunction + " Write a debug entry. Only writes when both logging and debug_mode are on. function! plugin_manager#core#log#debug(component, message) abort - if get(g:, 'plugin_manager_enable_logging', 1) && get(g:, 'plugin_manager_debug_mode', 0) - let l:parsed = {'type': 'external', 'component': a:component, 'code': 'DEBUG', 'message': 'DEBUG:' . a:component . ':DEBUG:' . a:message} - call plugin_manager#core#log#write(l:parsed) - endif + call s:leveled('debug', a:component, a:message) endfunction " Write a warn entry. Failure details: always written (a swallowed " failure must never depend on debug_mode being on - see issue #5). function! plugin_manager#core#log#warn(component, message) abort - if get(g:, 'plugin_manager_enable_logging', 1) - let l:parsed = {'type': 'internal', 'component': a:component, 'code': 'WARN', 'message': a:message} - call plugin_manager#core#log#write(l:parsed) - endif + call s:leveled('warn', a:component, a:message) endfunction " Write a trace entry. Gated by enable_logging only (callers gate on " g:plugin_manager_trace_commands before calling). function! plugin_manager#core#log#trace(component, message) abort - if get(g:, 'plugin_manager_enable_logging', 1) - let l:parsed = {'type': 'external', 'component': a:component, 'code': 'TRACE', 'message': 'TRACE:' . a:component . ':TRACE:' . a:message} - call plugin_manager#core#log#write(l:parsed) - endif + call s:leveled('trace', a:component, a:message) endfunction diff --git a/autoload/plugin_manager/core/util.vim b/autoload/plugin_manager/core/util.vim index 29e9c1e..92be615 100644 --- a/autoload/plugin_manager/core/util.vim +++ b/autoload/plugin_manager/core/util.vim @@ -164,14 +164,20 @@ endfunction " Strip https/http userinfo (user:token@) from a URL: logs, error messages " and commit texts must never carry credentials. +" Strip userinfo from an https/http URL (single occurrence or all with +" a:flags='g', for command strings embedding several URLs). +function! s:sanitize_url_impl(url, flags) abort + return substitute(a:url, '\(https\?://\)[^/@]*@', '\1', a:flags) +endfunction + function! plugin_manager#core#util#sanitize_url(url) abort - return substitute(a:url, '\(https\?://\)[^/@]*@', '\1', '') + return s:sanitize_url_impl(a:url, '') endfunction " Strip userinfo from every https/http URL inside a command string (traces " and error messages embed whole commands). function! plugin_manager#core#util#sanitize_cmd(cmd) abort - return substitute(a:cmd, '\(https\?://\)[^/@]*@', '\1', 'g') + return s:sanitize_url_impl(a:cmd, 'g') endfunction " Validate a branch name coming from .gitmodules or user declarations. diff --git a/tests/log.vader b/tests/log.vader index 42f4d2b..2941d16 100644 --- a/tests/log.vader +++ b/tests/log.vader @@ -37,7 +37,7 @@ Execute (log#debug is gated by debug_mode): let g:plugin_manager_debug_mode = 1 call plugin_manager#core#log#debug('test', 'shown') let g:log_lines = readfile(plugin_manager#core#log#get_path()) - Assert g:log_lines[-1] =~# 'DEBUG:test:DEBUG:shown$', 'debug on: entry written' + Assert g:log_lines[-1] =~# '| test | EXTERNAL | DEBUG:shown$', 'debug on: entry written' unlet g:plugin_manager_debug_mode unlet g:before unlet g:log_lines From 1695d71019c8126810a6c8bf0be8bdac3caf2281 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:55:25 +0200 Subject: [PATCH 06/10] test(update): pin the exact detached-HEAD and custom-branch skip messages --- tests/update.vader | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/update.vader b/tests/update.vader index 21ec010..999c318 100644 --- a/tests/update.vader +++ b/tests/update.vader @@ -305,3 +305,24 @@ Execute (fetch failure is reported and the failed module is excluded): \ 'the fetch failure must be logged, got: ' . g:_pm_upd_logtext unlet g:_pm_upd_sb g:_pm_upd_head g:_pm_upd_after g:_pm_upd_logtext unlet g:_pm_upd_plug2 + +Execute (detached HEAD module is skipped with the exact skip message): + " Characterization: the skip message is shared by both update paths and + " must stay verbatim (UI copy contract). + call system('git -C ' . shellescape(g:_pm_upd_plug) . ' checkout -q --detach HEAD') + call plugin_manager#cmd#dispatch('update', 'myplugin') + let g:_pm_upd_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") + Assert g:_pm_upd_sb =~# 'Detached HEAD: skipped (declare a tag/commit to pin it)', + \ 'exact detached-HEAD skip message expected, got: ' . g:_pm_upd_sb + call system('git -C ' . shellescape(g:_pm_upd_plug) . ' checkout -q main') + unlet g:_pm_upd_sb + +Execute (custom-branch module is skipped with the exact skip message): + call system('git -C ' . shellescape(g:_pm_upd_plug) . ' checkout -qb dev') + call plugin_manager#cmd#dispatch('update', 'myplugin') + let g:_pm_upd_sb = join(getbufline(bufnr('PluginManager'), 1, '$'), "\n") + Assert g:_pm_upd_sb =~# 'On custom branch', + \ 'custom-branch skip message expected, got: ' . g:_pm_upd_sb + call system('git -C ' . shellescape(g:_pm_upd_plug) . ' checkout -q main') + call system('git -C ' . shellescape(g:_pm_upd_plug) . ' branch -D dev') + unlet g:_pm_upd_sb From 7a29a0be4ed6e627512ff5b196530c66666498e7 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:56:44 +0200 Subject: [PATCH 07/10] refactor(update): extract shared pin/skip/pull helpers from both paths The single-plugin and all-plugins update paths duplicated three blocks verbatim: the pinned-module handoff (s:handle_pin), the detached-HEAD / custom-branch skip text (s:skip_status, UI copy now pinned by tests/update.vader) and the pull command construction (s:pull_cmd). Path-specific logic that differs (pull-step UI text 'Pulling changes' vs 'Updating', stash bookkeeping, helptags+auto-commit in the single path, batch finalize) stays per-path: merging the flows would trade real behavior differences for churn. Full pipeline unification is deliberately NOT done (audit over-flagged it). --- autoload/plugin_manager/cmd/update.vim | 86 ++++++++++++++++---------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/autoload/plugin_manager/cmd/update.vim b/autoload/plugin_manager/cmd/update.vim index a604055..f09c171 100644 --- a/autoload/plugin_manager/cmd/update.vim +++ b/autoload/plugin_manager/cmd/update.vim @@ -104,21 +104,16 @@ function! s:on_fetch_complete(ctx, result) abort let a:ctx.current_commit = l:update_status.current_commit " A declared tag/commit pin replaces the pull flow entirely - let l:pin = s:pin_for(a:ctx.pins, get(a:ctx, 'current_module', {})) - if !empty(l:pin) && (has_key(l:pin, 'tag') || has_key(l:pin, 'commit')) - call s:sync_pinned(a:ctx, a:ctx.current_module, l:pin, - \ l:update_status.current_commit, l:op_id) + if s:handle_pin(a:ctx, a:ctx.current_module, l:update_status, l:op_id) return endif " A custom branch or a detached HEAD without a declaration is never " pulled: the pull would fail noisily on the detached HEAD or destroy a " manually checked out revision. Declare a tag/commit to pin it instead. - if l:update_status.different_branch || l:update_status.branch ==# 'detached' - call plugin_manager#ui#complete_operation(l:op_id, 'skip', - \ l:update_status.branch ==# 'detached' - \ ? 'Detached HEAD: skipped (declare a tag/commit to pin it)' - \ : 'On custom branch') + let l:skip = s:skip_status(l:update_status) + if !empty(l:skip) + call plugin_manager#ui#complete_operation(l:op_id, 'skip', l:skip) return endif @@ -132,9 +127,7 @@ function! s:on_fetch_complete(ctx, result) abort " Step 3: Pull call plugin_manager#ui#update_operation(l:op_id, 'Pulling changes') - let l:pull_flag = plugin_manager#core#util#get_pull_flag() - let l:branch = plugin_manager#git#remote_branch_name(l:update_status.remote_branch) - call plugin_manager#async#git('git -C ' . shellescape(l:module_path) . ' pull origin ' . shellescape(l:branch) . ' ' . l:pull_flag, { + call plugin_manager#async#git(s:pull_cmd(l:module_path, l:update_status.remote_branch), { \ 'callback': function('s:on_update_complete', [a:ctx]) \ }) endfunction @@ -173,6 +166,7 @@ function! s:on_update_complete(ctx, result) abort endfunction " Surface detailed error output from a failed async job to the log + function! s:report_job_errors(result) abort let l:detail = '' if has_key(a:result, 'errors') && !empty(a:result.errors) @@ -233,6 +227,44 @@ function! s:pin_for(pins, module) abort \ get(a:pins, 'name:' . get(a:module, 'short_name', ''), {})) endfunction +" Shared pinned-module handling (identical in the single-plugin and +" all-plugins paths): a declared tag/commit pin replaces the pull flow +" entirely. Returns 1 when the module was handled by its pin. +function! s:handle_pin(ctx, module, update_status, op_id) abort + let l:pin = s:pin_for(a:ctx.pins, a:module) + if empty(l:pin) || !(has_key(l:pin, 'tag') || has_key(l:pin, 'commit')) + return 0 + endif + " Record the pre-checkout commit when the context tracks them + " (all-plugins path): s:on_pin_checkout compares against it. + " Without it a pin move reports Up-to-date and skips the pointer commit. + if has_key(a:ctx, 'pre_commits') + let a:ctx.pre_commits[a:module.short_name] = a:update_status.current_commit + endif + call s:sync_pinned(a:ctx, a:module, l:pin, + \ a:update_status.current_commit, a:op_id) + return 1 +endfunction + +" Shared skip text for a module on a detached HEAD or a custom branch +" (identical in both update paths; UI copy contract - see update.vader). +" Returns the skip message, or '' when the module is on a pullable branch. +function! s:skip_status(update_status) abort + if a:update_status.different_branch || a:update_status.branch ==# 'detached' + return a:update_status.branch ==# 'detached' + \ ? 'Detached HEAD: skipped (declare a tag/commit to pin it)' + \ : 'On custom branch' + endif + return '' +endfunction + +" Shared pull command builder (identical in both update paths). +function! s:pull_cmd(module_path, remote_branch) abort + return 'git -C ' . shellescape(a:module_path) . ' pull origin ' + \ . shellescape(plugin_manager#git#remote_branch_name(a:remote_branch)) + \ . ' ' . plugin_manager#core#util#get_pull_flag() +endfunction + " Resolve the pin target and checkout it when HEAD differs. Replaces the " pull flow for pinned modules: a detached-at-tag submodule must never be " pulled. @@ -410,25 +442,18 @@ function! s:analyze_and_update(ctx, module) abort let l:update_status = plugin_manager#git#collect_status_local(l:module_path) - " A declared tag/commit pin replaces the pull flow entirely - let l:pin = s:pin_for(get(a:ctx, 'pins', {}), a:module) - if !empty(l:pin) && (has_key(l:pin, 'tag') || has_key(l:pin, 'commit')) - " Record the pre-checkout commit: s:on_pin_checkout compares against it - " in the all-plugins path (the single-plugin path uses current_commit). - " Without it a pin move reports Up-to-date and skips the pointer commit. - if has_key(a:ctx, 'pre_commits') - let a:ctx.pre_commits[a:module.short_name] = l:update_status.current_commit - endif - call s:sync_pinned(a:ctx, a:module, l:pin, - \ l:update_status.current_commit, l:op_id) + " A declared tag/commit pin replaces the pull flow entirely. + " Record the pre-checkout commit when the all-plugins context tracks + " them: s:on_pin_checkout compares against it (the single-plugin path + " keeps its own current_commit). Without it a pin move reports + " Up-to-date and skips the pointer commit. + if s:handle_pin(a:ctx, a:module, l:update_status, l:op_id) return endif - if l:update_status.different_branch || l:update_status.branch ==# 'detached' - call plugin_manager#ui#complete_operation(l:op_id, 'skip', - \ l:update_status.branch ==# 'detached' - \ ? 'Detached HEAD: skipped (declare a tag/commit to pin it)' - \ : 'On custom branch') + let l:skip = s:skip_status(l:update_status) + if !empty(l:skip) + call plugin_manager#ui#complete_operation(l:op_id, 'skip', l:skip) let a:ctx.pending -= 1 call s:maybe_finalize(a:ctx) return @@ -450,10 +475,7 @@ function! s:analyze_and_update(ctx, module) abort " Pull with the correct remote branch (use -C with absolute path) call plugin_manager#ui#update_operation(l:op_id, 'Updating') - let l:branch = plugin_manager#git#remote_branch_name(l:update_status.remote_branch) - let l:pull_flag = plugin_manager#core#util#get_pull_flag() - let l:update_cmd = 'git -C ' . shellescape(l:module_path) . ' pull origin ' . shellescape(l:branch) . ' ' . l:pull_flag - call plugin_manager#async#git(l:update_cmd, { + call plugin_manager#async#git(s:pull_cmd(l:module_path, l:update_status.remote_branch), { \ 'callback': function('s:on_module_updated', [a:ctx, a:module]) \ }) endfunction From 5ec550a9357df756fd6327284afdff647da2c9ba Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:58:11 +0200 Subject: [PATCH 08/10] docs: update tree lists and changelog for the perf/dead-code pass - CONTRIBUTING: drop ftdetect/ and Makefile.test from the tree, list the new perf.vader and health.vader characterization suites - CHANGELOG: [Unreleased] documents the subprocess batching, the health refactor, the single level prefix in debug/trace log lines, the now-real opts.force and the dead-code removals --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 7 +++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c362aa..3895fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ All notable changes to the Vim Plugin Manager will be documented in this file. +## [Unreleased] + +### Performance +- `git#collect_status_local`: worst case reduced from 9 git subprocesses + per module to 7 (6 when a branch is declared in `.gitmodules`). The + branch now comes from the mtime-cached `parse_modules` result instead + of a per-module `git config -f` call, and the sequential + upstream/show-ref cascade collapses into one `git for-each-ref` call + (same selection order; its sha listing also replaces the remote + `rev-parse` for `origin/*` refs - verbatim `.gitmodules` branches keep + their `rev-parse` since they resolve local refs). The direct + `.gitmodules` read is kept when `parse_modules` is unavailable so + hostile branch values still reach `sanitize_branch` (#9 parity). + `tests/perf.vader` pins the subprocess budget as a permanent + regression guard. +- Health check rendering: the 200-line sequential function is now a + table of check Funcrefs; per-check report order and messages are + unchanged (pinned by a characterization test). + +### Changed +- Log format: debug/trace entries carry a single level prefix + (`| comp | EXTERNAL | DEBUG:msg`) instead of the doubled + `DEBUG:comp:DEBUG:msg`. The `code` field was previously always + `EXTERNAL` for those entries; the level prefix is the marker. +- `check` opts: the documented `force` option is now real - it always + fetches and leaves the update-check cache untouched, so the next + startup check re-fetches (TTL skipped). It was previously accepted + but ignored. + +### Removed +- Dead code: never-read async job state fields (`id`, `started`, + `queued`), the unused `g:plugin_manager_periodic_timer` write, four + sidebar highlight keywords no code emits (`Synced`, `Skipped`, + `timed out`, `Stashing changes`), the `pending` UI glyph key, the + legacy numeric status branch of `ui#get_status_glyph` (all callers + pass keywords), the unreachable replace path of `ui#update_sidebar`, + the list branch of `ui#log_detail`, `ftdetect/pluginmanager.vim` (only + matched a literal file named `PluginManager`) and the `Makefile.test` + forwarding shim. +- Internal refactors with zero behavior change: shared userinfo + sanitizer, shared log level writer, shared update-path helpers + (`s:handle_pin`, `s:skip_status`, `s:pull_cmd`), redundant + `get_config` re-read in backup. Deliberately NOT unified: the + single-plugin and all-plugins update flows (real behavior differences: + pull-step UI text, stash bookkeeping, helptags/auto-commit scope, + batch finalize). + + ## [2.2.10] - 2026-09-20 ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 183ab6a..ec94fd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,7 +219,6 @@ The project is organized into several key components: - `remote.vim`: Remote repository management. 6. **Utility Files** - - `ftdetect/pluginmanager.vim`: Defines filetype detection rules. - `ftplugin/pluginmanager.vim`: Sets buffer configuration and key mappings. - `syntax/pluginmanager.vim`: Defines syntax highlighting for the plugin interface. @@ -452,7 +451,7 @@ To run vint locally (requires Python): ```bash pip install vim-vint -vint -e autoload/ plugin/ ftplugin/ ftdetect/ syntax/ +vint -e autoload/ plugin/ ftplugin/ syntax/ ``` When adding new features or fixing bugs: @@ -517,8 +516,6 @@ Understanding the project's complete structure will help you contribute effectiv │ └── update.vim # Plugin update ├── doc/ # Vim help documentation │ └── plugin_manager.txt # :help plugin-manager -├── ftdetect/ # Filetype detection -│ └── pluginmanager.vim # Registers the pluginmanager filetype ├── ftplugin/ # Filetype plugin │ └── pluginmanager.vim # Buffer settings and key mappings ├── plugin/ # Plugin entry point @@ -535,6 +532,8 @@ Understanding the project's complete structure will help you contribute effectiv │ ├── declare.vader # Declarative Plugin/Begin/End blocks │ ├── dispatch.vader # Command dispatch and tab completion │ ├── gitmodules.vader # .gitmodules parsing and module lookup +│ ├── health.vader # Health check statuses (characterized) +│ ├── perf.vader # collect_status_local subprocess budget │ ├── remove.vader # Plugin removal and ambiguity guard │ ├── restore.vader # Submodule restoration from .gitmodules │ ├── status.vader # Status block rendering From 08072b6e41d10a3ad58bf3584b21bc5e779ab369 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:32:34 +0200 Subject: [PATCH 09/10] fix(git): anchor git config -f at the vim dir, fix perf fixture branch setup Two CI-only failures, both git-environment dependent: - the fixture renamed HEAD to main AFTER the first commit; on stock images (init.defaultBranch unset -> master) that left HEAD an unborn symref, so `update-ref refs/remotes/origin/main HEAD` failed and the cascade found no remote branch. Point HEAD at main BEFORE the commit. - `git config -f` calls ran without -C: git does repo discovery from the cwd first, so a cwd inside an unrelated (or broken) repository could leak in or abort. Both parse_modules and the collect_status_local fallback now use `git -C config -f ...`. Verified in an AlmaLinux 9 container (vim 8.2.2637, git 2.52): 214/214 Vader + 16/16 async smoke. --- autoload/plugin_manager/git.vim | 11 ++++++++--- dbgmark | 1 + m2.out | 1 + tests/perf.vader | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 dbgmark create mode 100644 m2.out diff --git a/autoload/plugin_manager/git.vim b/autoload/plugin_manager/git.vim index 70ece2a..a860f58 100644 --- a/autoload/plugin_manager/git.vim +++ b/autoload/plugin_manager/git.vim @@ -41,8 +41,11 @@ function! plugin_manager#git#parse_modules() abort " Output format: 'submodule.. ' " Using --get-regexp lets git handle quoting, whitespace, and encoding. " throw_on_error=0: a missing/empty .gitmodules exits non-zero - not an error. + " -C anchors repo discovery at the vim dir: the command must not depend + " on the cwd (a cwd inside an unrelated repo must never leak in). let l:res = plugin_manager#git#execute( - \ 'git config -f ' . shellescape(l:gitmodules) . + \ 'git -C ' . shellescape(l:vim_dir) . + \ ' config -f ' . shellescape(l:gitmodules) . \ ' --get-regexp ''^submodule\.''', \ '', 0, 0) @@ -395,9 +398,11 @@ function! plugin_manager#git#collect_status_local(module_path) abort if empty(l:remote_branch) && empty(l:modules) " parse_modules is unavailable (vim_dir is not a git repo): keep the " direct .gitmodules read so untrusted branch values still reach - " sanitize_branch (which warns, see issue #9). + " sanitize_branch (which warns, see issue #9). -C anchors discovery + " at the vim dir like parse_modules does. let l:res = plugin_manager#git#execute( - \ 'git config -f ' . shellescape(plugin_manager#core#util#get_config('vim_dir', '') . '/.gitmodules') . + \ 'git -C ' . shellescape(plugin_manager#core#util#get_config('vim_dir', '')) . + \ ' config -f ' . shellescape(plugin_manager#core#util#get_config('vim_dir', '') . '/.gitmodules') . \ ' submodule.' . shellescape(l:rel_path) . '.branch', \ '', 0, 0) let l:remote_branch = l:res.success ? substitute(l:res.output, '\n', '', 'g') : '' diff --git a/dbgmark b/dbgmark new file mode 100644 index 0000000..7f92052 --- /dev/null +++ b/dbgmark @@ -0,0 +1 @@ +START diff --git a/m2.out b/m2.out new file mode 100644 index 0000000..5f8ac41 --- /dev/null +++ b/m2.out @@ -0,0 +1 @@ +v=1 vt=802 diff --git a/tests/perf.vader b/tests/perf.vader index abd50f2..4b5c7e3 100644 --- a/tests/perf.vader +++ b/tests/perf.vader @@ -30,15 +30,17 @@ Before: " Module repo on branch main with a remote-tracking origin/main and no " upstream (worst-case remote-branch resolution: full cascade). + " Point HEAD at main BEFORE the first commit: HEAD is an unborn symref + " until then, and init.defaultBranch varies (master on stock CI images). let g:_pm_perf_mod = '/tmp/pm-perf-test/vim/pack/plugins/start/foo' call mkdir(g:_pm_perf_mod, 'p') call system('git init -q ' . shellescape(g:_pm_perf_mod)) + call system('git -C ' . shellescape(g:_pm_perf_mod) . ' symbolic-ref HEAD refs/heads/main') call system('git -C ' . shellescape(g:_pm_perf_mod) . ' config user.email "t@t.com"') call system('git -C ' . shellescape(g:_pm_perf_mod) . ' config user.name "T"') call writefile(['x'], g:_pm_perf_mod . '/f') call system('git -C ' . shellescape(g:_pm_perf_mod) . ' add .') call system('git -C ' . shellescape(g:_pm_perf_mod) . ' commit -qm init') - call system('git -C ' . shellescape(g:_pm_perf_mod) . ' symbolic-ref HEAD refs/heads/main') call system('git -C ' . shellescape(g:_pm_perf_mod) . ' update-ref refs/remotes/origin/main HEAD') " .gitmodules without a branch key: the branch must come from the cascade. From f3164c5952f977a04ac03e151c9b3b4a2c33b150 Mon Sep 17 00:00:00 2001 From: log0u7 <70974447+log0u7@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:32:40 +0200 Subject: [PATCH 10/10] chore: drop debug artifacts committed by mistake --- dbgmark | 1 - m2.out | 1 - 2 files changed, 2 deletions(-) delete mode 100644 dbgmark delete mode 100644 m2.out diff --git a/dbgmark b/dbgmark deleted file mode 100644 index 7f92052..0000000 --- a/dbgmark +++ /dev/null @@ -1 +0,0 @@ -START diff --git a/m2.out b/m2.out deleted file mode 100644 index 5f8ac41..0000000 --- a/m2.out +++ /dev/null @@ -1 +0,0 @@ -v=1 vt=802