From 9317f4b5078c90aee517b362099900f8f34c21ef Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 24 May 2026 17:07:01 +0300 Subject: [PATCH] vpm: normalize module path on update/remove (fix #27192) (#27210) --- cmd/tools/vpm/common.v | 21 +++++++++++++++++++-- cmd/tools/vpm/dependency_test.v | 11 ++++++----- cmd/tools/vpm/install.v | 6 +++++- cmd/tools/vpm/install_local_test.v | 23 +++++++++++++++++++++++ cmd/tools/vpm/install_test.v | 16 +++++++++++----- cmd/tools/vpm/outdated_test.v | 4 ++-- cmd/tools/vpm/parse.v | 13 +++++++++++++ cmd/tools/vpm/update.v | 13 ++++++++++--- cmd/tools/vpm/update_test.v | 8 ++++---- cmd/tools/vpm/vpm.v | 2 +- 10 files changed, 94 insertions(+), 23 deletions(-) diff --git a/cmd/tools/vpm/common.v b/cmd/tools/vpm/common.v index 6650ba244..a051db8be 100644 --- a/cmd/tools/vpm/common.v +++ b/cmd/tools/vpm/common.v @@ -308,8 +308,25 @@ fn get_installed_modules() []string { } fn get_path_of_existing_module(mod_name string) ?string { - name := get_name_from_url(mod_name) or { mod_name.replace('-', '_').to_lower() } - path := os.real_path(os.join_path(settings.vmodules_path, name.replace('.', os.path_separator))) + // When given a URL, also try the publisher/name layout used by + // registered-name installations (e.g. `/spytheman/vtray` for + // `https://github.com/spytheman/vtray`), in addition to the bare name. + is_url := mod_name.starts_with('http://') || mod_name.starts_with('https://') + || mod_name.starts_with('git@') + if is_url { + publisher, name := get_ident_from_url(mod_name) or { '', '' } + if publisher != '' && name != '' { + rel_path := normalize_mod_path(os.join_path(publisher, name)) + path := os.real_path(os.join_path(settings.vmodules_path, rel_path)) + if os.exists(path) && os.is_dir(path) { + verbose_println_more(@FILE_LINE, @FN, 'mod_name: ${mod_name}, found path: ${path}') + return path + } + } + } + name := get_name_from_url(mod_name) or { mod_name } + rel_path := normalize_mod_path(name.replace('.', os.path_separator)) + path := os.real_path(os.join_path(settings.vmodules_path, rel_path)) if !os.exists(path) { vpm_error('failed to find `${name}` at `${path}`.') return none diff --git a/cmd/tools/vpm/dependency_test.v b/cmd/tools/vpm/dependency_test.v index 73c9eff20..6a8d3d663 100644 --- a/cmd/tools/vpm/dependency_test.v +++ b/cmd/tools/vpm/dependency_test.v @@ -57,11 +57,11 @@ fn test_install_dependencies_in_module_dir() { assert res.output.contains('Detected v.mod file inside the project directory. Using it...'), res.output expect_installing(@LOCATION, res.output, 'markdown') expect_installing(@LOCATION, res.output, 'pcre') - expect_installing(@LOCATION, res.output, 'vtray') + expect_installing(@LOCATION, res.output, 'spytheman.vtray') assert get_mod_name(os.join_path(test_path, 'markdown', 'v.mod')) == 'markdown' assert get_mod_name(os.join_path(test_path, 'pcre', 'v.mod')) == 'pcre' - assert get_mod_name(os.join_path(test_path, 'vtray', 'v.mod')) == 'vtray' + assert get_mod_name(os.join_path(test_path, 'spytheman', 'vtray', 'v.mod')) == 'vtray' res = cmd_ok(@LOCATION, '${v} install --once') assert res.output.contains('All modules are already installed.'), res.output } @@ -69,10 +69,11 @@ fn test_install_dependencies_in_module_dir() { fn test_resolve_external_dependencies_during_module_install() { res := cmd_ok(@LOCATION, '${v} install -v https://github.com/ttytm/emoji-mart-desktop') assert res.output.contains('Found 2 dependencies'), res.output - expect_installing(@LOCATION, res.output, 'webview') + expect_installing(@LOCATION, res.output, 'ttytm.webview') expect_installing(@LOCATION, res.output, 'miniaudio') - // The external dependencies should have been installed to `/` - assert get_mod_name(os.join_path(test_path, 'webview', 'v.mod')) == 'webview' + // `ttytm.webview` is a registered VPM module, so it lands in `/ttytm/webview`. + // `miniaudio` is unregistered, so it lands in `/miniaudio`. + assert get_mod_name(os.join_path(test_path, 'ttytm', 'webview', 'v.mod')) == 'webview' assert get_mod_name(os.join_path(test_path, 'miniaudio', 'v.mod')) == 'miniaudio' } diff --git a/cmd/tools/vpm/install.v b/cmd/tools/vpm/install.v index 975872058..a75c09e50 100644 --- a/cmd/tools/vpm/install.v +++ b/cmd/tools/vpm/install.v @@ -195,7 +195,11 @@ fn local_git_changes_reason(path string) string { // Include `HEAD` so commits made on a detached HEAD (e.g. after // `git clone -b `, the layout vpm uses for versioned installs) are // also detected. `--branches` alone only walks local branch refs. - unpushed := os.execute_opt('git -C ${quoted} rev-list HEAD --branches --not --remotes') or { + // Negate `--tags` as well: vpm's versioned installs clone with `-b `, + // which leaves HEAD detached at a tag without creating a remote tracking + // branch, so HEAD would otherwise appear as unpushed even on a pristine + // clone. + unpushed := os.execute_opt('git -C ${quoted} rev-list HEAD --branches --not --remotes --tags') or { return 'failed to run `git rev-list`: ${err.msg()}' } if unpushed.output.trim_space() != '' { diff --git a/cmd/tools/vpm/install_local_test.v b/cmd/tools/vpm/install_local_test.v index 7a5baf8d4..ddcacf638 100644 --- a/cmd/tools/vpm/install_local_test.v +++ b/cmd/tools/vpm/install_local_test.v @@ -91,6 +91,29 @@ fn test_install_from_local_git_repository_variants() { } } +// Regression test for https://github.com/vlang/v/issues/27192. +// VPM-registered installs lowercase the on-disk path via `normalize_mod_path`, +// so `v update ` and `v remove ` must apply the same +// normalization when looking up existing modules — otherwise users with +// capitalized publisher names (e.g. `Frothy7650.chalk`) cannot update or +// remove the modules they just installed. +fn test_update_and_remove_with_capitalized_ident() { + vmodules_path := os.join_path(test_path, 'vmodules_capitalized') + test_utils.set_test_env(vmodules_path) + // Simulate the post-install state of `v install Frothy7650.chalk`: + // a real VPM install places the module under the lowercased publisher dir. + publisher_dir := os.join_path(vmodules_path, 'frothy7650') + installed_path := os.join_path(publisher_dir, 'chalk') + os.mkdir_all(installed_path) or { panic(err) } + os.write_file(os.join_path(installed_path, 'v.mod'), + "Module{\n\tname: 'Frothy7650.chalk'\n\tversion: '0.0.1'\n}\n") or { panic(err) } + // Remove with the original (capitalized) ident must succeed and clean up the author dir. + res := cmd_ok(@LOCATION, '${vexe} remove Frothy7650.chalk') + assert !res.output.contains('failed to find'), res.output + assert !os.exists(installed_path) + assert !os.exists(publisher_dir) +} + fn create_local_git_module(repo_path string, module_name string) { os.mkdir_all(repo_path) or { panic(err) } os.write_file(os.join_path(repo_path, 'v.mod'), diff --git a/cmd/tools/vpm/install_test.v b/cmd/tools/vpm/install_test.v index 2a906ff05..e7b818131 100644 --- a/cmd/tools/vpm/install_test.v +++ b/cmd/tools/vpm/install_test.v @@ -68,12 +68,12 @@ fn test_install_from_git_url_uses_registered_package_name() { assert res.output.contains('Installing `nedpals.args`'), res.output assert res.output.contains('Installed `nedpals.args`'), res.output mut manifest := get_vmod(os.join_path('nedpals', 'args')) - assert manifest.name == 'args' + assert manifest.name == 'nedpals.args' res = cmd_ok(@LOCATION, '${vexe} install https://github.com/nedpals/v-args') assert res.output.contains('Updating module `nedpals.args`'), res.output manifest = get_vmod(os.join_path('nedpals', 'args')) - assert manifest.name == 'args' + assert manifest.name == 'nedpals.args' } fn test_install_already_existent() { @@ -134,14 +134,20 @@ fn test_install_potentially_conflicting() { mut manifest := get_vmod('ui') assert manifest.name == 'ui' res = os.execute('${vexe} install https://github.com/isaiahpatton/ui') - assert res.output.contains('Installed `iui`') - manifest = get_vmod('iui') + // The VPM registry maps `github.com/isaiahPatton/ui` (whose manifest is + // named `iui`) to the registered name `IsaiahPatton.iui`, so the install + // now uses the registered name and the publisher-prefixed path. + assert res.output.contains('Installed `IsaiahPatton.iui`'), res.output + manifest = get_vmod(os.join_path('isaiahpatton', 'iui')) assert manifest.name == 'iui' } fn test_get_installed_version() { test_project_path := os.join_path(test_path, 'test_project') - mut res := cmd_ok(@LOCATION, 'git init ${test_project_path}') + // Force the initial branch name; CI ships with git's traditional `master` + // default, but newer git installs (and many dev machines) default to + // `main`, which makes the `git branch -D master` step below fail. + mut res := cmd_ok(@LOCATION, 'git init -b master ${test_project_path}') os.chdir(test_project_path)! if os.execute('git config user.name').exit_code == 1 { os.execute_or_exit('git config user.email "ci@vlang.io"') diff --git a/cmd/tools/vpm/outdated_test.v b/cmd/tools/vpm/outdated_test.v index a16e8c082..ab45cd896 100644 --- a/cmd/tools/vpm/outdated_test.v +++ b/cmd/tools/vpm/outdated_test.v @@ -53,7 +53,7 @@ fn test_outdated() { cmd_ok(@LOCATION, '${vexe} install ${m}') } // "Outdate" previously installed. Leave out `libsodium`. - for m in ['pcre', 'vtray', os.join_path('nedpals', 'args')] { + for m in ['pcre', os.join_path('spytheman', 'vtray'), os.join_path('nedpals', 'args')] { cmd_ok(@LOCATION, 'git -C ${m} fetch --all') cmd_ok(@LOCATION, 'git -C ${m} reset --hard HEAD~') assert is_outdated(m) @@ -62,7 +62,7 @@ fn test_outdated() { output := res.output.all_after('Outdated modules:') assert output.len > 0, output assert output.contains('pcre'), output - assert output.contains('vtray'), output + assert output.contains('spytheman.vtray'), output assert output.contains('nedpals.args'), output assert !output.contains('libsodium'), output } diff --git a/cmd/tools/vpm/parse.v b/cmd/tools/vpm/parse.v index e364410d6..dcf2aacde 100644 --- a/cmd/tools/vpm/parse.v +++ b/cmd/tools/vpm/parse.v @@ -257,6 +257,19 @@ fn is_local_repository(query string) bool { return true } if os.exists(path) { + // A bare relative name like `vsl` is ambiguous: it might be a + // registered VPM module, or a like-named local directory in the + // caller's cwd. If the candidate resolves to a path inside + // `settings.vmodules_path`, it is just a previously installed + // module shadowing the registered name — don't treat it as a + // local repository. This keeps `v install vsl@` working when + // cwd happens to be the vmodules directory (the test setup for + // versioned installs does exactly this). + abs := os.real_path(path) + vmodules_real := os.real_path(settings.vmodules_path) + if abs.starts_with(vmodules_real + os.path_separator) || abs == vmodules_real { + continue + } return true } } diff --git a/cmd/tools/vpm/update.v b/cmd/tools/vpm/update.v index cafb2f427..a52750549 100644 --- a/cmd/tools/vpm/update.v +++ b/cmd/tools/vpm/update.v @@ -36,12 +36,19 @@ fn vpm_update(query []string) { fn update_module(mut pp pool.PoolProcessor, idx int, _wid int) &UpdateResult { ident := pp.get_item[string](idx) - // Usually, the module `ident`ifier. `get_name_from_url` is only relevant for `v update `. - name := get_name_from_url(ident) or { ident } install_path := get_path_of_existing_module(ident) or { - vpm_error('failed to find path for `${name}`.', verbose: true) + fallback_name := get_name_from_url(ident) or { ident } + vpm_error('failed to find path for `${fallback_name}`.', verbose: true) return &UpdateResult{} } + // Derive the canonical module name from the install path so URL-based + // updates report the registered name (e.g. `spytheman.vtray` for + // `/spytheman/vtray`) instead of the bare URL-derived `vtray`. + // Normalize both sides via real_path so macOS's `/tmp` -> `/private/tmp` + // resolution doesn't leave the prefix unstripped. + vmodules_real := os.real_path(settings.vmodules_path) + rel_install_path := install_path.trim_string_left(vmodules_real).trim_left(os.path_separator) + name := rel_install_path.replace(os.path_separator, '.') vcs := vcs_used_in_dir(install_path) or { vpm_error('failed to find version control system for `${name}`.', verbose: true) return &UpdateResult{} diff --git a/cmd/tools/vpm/update_test.v b/cmd/tools/vpm/update_test.v index 583239c92..c0dd85108 100644 --- a/cmd/tools/vpm/update_test.v +++ b/cmd/tools/vpm/update_test.v @@ -28,7 +28,7 @@ fn test_update() { res := cmd_ok(@LOCATION, '${v} update') assert res.output.contains('Updating module `pcre`'), res.output assert res.output.contains('Updating module `nedpals.args`'), res.output - assert res.output.contains('Updating module `vtray`'), res.output + assert res.output.contains('Updating module `spytheman.vtray`'), res.output assert res.output.contains('Skipping download count increment for `nedpals.args`.'), res.output assert res.output.contains('Skipping download count increment for `pcre`.'), res.output } @@ -36,12 +36,12 @@ fn test_update() { fn test_update_idents() { mut res := cmd_ok(@LOCATION, '${v} update pcre') assert res.output.contains('Updating module `pcre`'), res.output - res = cmd_ok(@LOCATION, '${v} update nedpals.args vtray') - assert res.output.contains('Updating module `vtray`'), res.output + res = cmd_ok(@LOCATION, '${v} update nedpals.args spytheman.vtray') + assert res.output.contains('Updating module `spytheman.vtray`'), res.output assert res.output.contains('Updating module `nedpals.args`'), res.output // Update installed module using its url. res = cmd_ok(@LOCATION, '${v} update https://github.com/spytheman/vtray') - assert res.output.contains('Updating module `vtray`'), res.output + assert res.output.contains('Updating module `spytheman.vtray`'), res.output // Try update not installed. res = cmd_fail(@LOCATION, '${v} update vsl') assert res.output.contains('failed to find `vsl`'), res.output diff --git a/cmd/tools/vpm/vpm.v b/cmd/tools/vpm/vpm.v index 85ecff5b0..9d29c011d 100644 --- a/cmd/tools/vpm/vpm.v +++ b/cmd/tools/vpm/vpm.v @@ -161,7 +161,7 @@ fn vpm_remove(query []string) { vpm_log(@FILE_LINE, @FN, 'removing: ${final_module_path}') rmdir_all(final_module_path) or { vpm_error(err.msg(), verbose: true) } // Delete author directory if it is empty. - author := m.split('.')[0] + author := normalize_mod_path(m.split('.')[0]) author_dir := os.real_path(os.join_path(settings.vmodules_path, author)) if !os.exists(author_dir) { continue -- 2.39.5