diff --git a/cmd/tools/vpm/common.v b/cmd/tools/vpm/common.v index b84f94e92a699a..2d40483425f464 100644 --- a/cmd/tools/vpm/common.v +++ b/cmd/tools/vpm/common.v @@ -19,6 +19,7 @@ mut: version string // specifies the requested version. install_path string is_installed bool + is_external bool installed_version string } @@ -48,22 +49,22 @@ fn parse_query(query []string) ([]Module, []Module) { mut errors := 0 for m in query { ident, version := m.rsplit_once('@') or { m, '' } - mut is_external := false mut mod := if ident.starts_with('https://') { - is_external = true name := get_name_from_url(ident) or { vpm_error(err.msg()) errors++ continue } - if !has_vmod(ident) { + install_path := os.real_path(os.join_path(settings.vmodules_path, name)) + if !has_vmod(ident, install_path) { errors++ continue } Module{ name: name url: ident - install_path: os.real_path(os.join_path(settings.vmodules_path, name)) + install_path: install_path + is_external: true } } else { info := get_mod_vpm_info(ident) or { @@ -85,7 +86,7 @@ fn parse_query(query []string) ([]Module, []Module) { mod.is_installed = true mod.installed_version = v.output.all_after_last('/').trim_space() } - if is_external { + if mod.is_external { external_modules << mod } else { vpm_modules << mod @@ -97,7 +98,11 @@ fn parse_query(query []string) ([]Module, []Module) { return vpm_modules, external_modules } -fn has_vmod(url string) bool { +fn has_vmod(url string, install_path string) bool { + if os.exists((os.join_path(install_path, 'v.mod'))) { + // Safe time fetchting the repo when the module is already installed and has a `v.mod`. + return true + } head_branch := os.execute_opt('git ls-remote --symref ${url} HEAD') or { vpm_error('failed to find git HEAD for `${url}`.', details: err.msg()) return false diff --git a/cmd/tools/vpm/dependency_test.v b/cmd/tools/vpm/dependency_test.v index 373356354fd369..d3922eac865a7f 100644 --- a/cmd/tools/vpm/dependency_test.v +++ b/cmd/tools/vpm/dependency_test.v @@ -50,10 +50,10 @@ fn test_install_dependencies_in_module_dir() { assert v_mod.dependencies == ['markdown', 'pcre', 'https://github.com/spytheman/vtray'] // Run `v install` res := os.execute_or_exit('${v} install') - assert res.output.contains('Detected v.mod file inside the project directory. Using it...') - assert res.output.contains('Installing module `markdown`') - assert res.output.contains('Installing module `pcre`') - assert res.output.contains('Installing module `vtray`') + assert res.output.contains('Detected v.mod file inside the project directory. Using it...'), res.output + assert res.output.contains('Installing `markdown`'), res.output + assert res.output.contains('Installing `pcre`'), res.output + assert res.output.contains('Installing `vtray`'), res.output 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' @@ -61,9 +61,9 @@ fn test_install_dependencies_in_module_dir() { fn test_resolve_external_dependencies_during_module_install() { res := os.execute_or_exit('${v} install https://github.com/ttytm/emoji-mart-desktop') - assert res.output.contains('Resolving 2 dependencies') - assert res.output.contains('Installing module `webview`') - assert res.output.contains('Installing module `miniaudio`') + assert res.output.contains('Resolving 2 dependencies'), res.output + assert res.output.contains('Installing `webview`'), res.output + assert res.output.contains('Installing `miniaudio`'), res.output // The external dependencies should have been installed to `/` assert get_mod_name(os.join_path(test_path, '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 0078e1fe9026cc..cce28eeea4f8aa 100644 --- a/cmd/tools/vpm/install.v +++ b/cmd/tools/vpm/install.v @@ -4,6 +4,12 @@ import os import v.vmod import v.help +enum InstallResult { + installed + failed + skipped +} + fn vpm_install(query []string) { if settings.is_help { help.print_and_exit('vpm') @@ -88,8 +94,8 @@ fn vpm_install_from_vpm(modules []Module) { last_errors := errors vcs := if m.vcs != '' { supported_vcs[m.vcs] or { - errors++ vpm_error('skipping `${m.name}`, since it uses an unsupported version control system `${m.vcs}`.') + errors++ continue } } else { @@ -100,18 +106,19 @@ fn vpm_install_from_vpm(modules []Module) { errors++ continue } - if os.exists(m.install_path) { - vpm_update([m.name]) - continue - } - m.install(vcs) or { - errors++ - vpm_error(err.msg()) - continue + match m.install(vcs) { + .installed {} + .failed { + errors++ + continue + } + .skipped { + continue + } } increment_module_download_count(m.name) or { - errors++ vpm_error('failed to increment the download count for `${m.name}`', details: err.msg()) + errors++ } if last_errors == errors { println('Installed `${m.name}`.') @@ -124,27 +131,27 @@ fn vpm_install_from_vpm(modules []Module) { } fn vpm_install_from_vcs(modules []Module) { - mut errors := 0 + vpm_log(@FILE_LINE, @FN, 'modules: ${modules}') vcs := supported_vcs[settings.vcs] + vcs.is_executable() or { + vpm_error(err.msg()) + exit(1) + } urls := modules.map(it.url) + mut errors := 0 for m in modules { vpm_log(@FILE_LINE, @FN, 'module: ${m}') last_errors := errors - if os.exists(m.install_path) { - vpm_update([m.name]) - continue - } - vcs.is_executable() or { - vpm_error(err.msg()) - errors++ - continue - } - m.install(vcs) or { - errors++ - vpm_error(err.msg()) - continue + match m.install(vcs) { + .installed {} + .failed { + errors++ + continue + } + .skipped { + continue + } } - // Note: increment error count when v.mod becomes mandatory for external modules. manifest := get_manifest(m.install_path) or { continue } final_path := os.real_path(os.join_path(settings.vmodules_path, manifest.name.replace('-', '_').to_lower())) @@ -201,13 +208,57 @@ fn vpm_install_from_vcs(modules []Module) { } } -fn (m Module) install(vcs &VCS) ! { - cmd := '${vcs.cmd} ${vcs.args.install} "${m.url}" "${m.install_path}"' +fn (m Module) install(vcs &VCS) InstallResult { + if m.is_installed { + // Case: installed, but not an explicit version. Update instead of continuing the installation. + if m.version == '' && m.installed_version == '' { + vpm_update([if m.is_external { m.url } else { m.name }]) + return .skipped + } + // Case: installed, but conflicting. Confirmation or -[-f]orce flag required. + if settings.is_force || m.confirm_install() { + m.remove() or { + vpm_error('failed to remove `${m.name}`.', details: err.msg()) + return .failed + } + } else { + return .skipped + } + } + install_arg := if m.version != '' { + '${vcs.args.install} --single-branch -b ${m.version}' + } else { + vcs.args.install + } + cmd := '${vcs.cmd} ${install_arg} "${m.url}" "${m.install_path}"' vpm_log(@FILE_LINE, @FN, 'command: ${cmd}') - println('Installing module `${m.name}` from `${m.url}` to `${m.install_path}` ...') - os.execute_opt(cmd) or { - vpm_log(@FILE_LINE, @FN, 'cmd output: ${err}') - return error('failed to install module `${m.name}` to `${m.install_path}`.') + println('Installing `${m.name}`...') + verbose_println(' cloning from `${m.url}` to `${m.install_path}`') + res := os.execute_opt(cmd) or { + vpm_error('failed to install `${m.name}`.', details: err.msg()) + return .failed + } + vpm_log(@FILE_LINE, @FN, 'cmd output: ${res.output}') + return .installed +} + +fn (m Module) confirm_install() bool { + if m.installed_version == m.version { + println('Module `${m.name}${at_version(m.installed_version)}` is already installed, use --force to overwrite.') + return false + } else { + install_version := at_version(if m.version == '' { 'latest' } else { m.version }) + println('Module `${m.name}${at_version(m.installed_version)}` is already installed at `${m.install_path}`.') + input := os.input('Replace it with `${m.name}${install_version}`? [Y/n]: ') + match input.to_lower() { + '', 'y' { + return true + } + else { + verbose_println('Skipping `${m.name}`.') + return false + } + } } } @@ -220,3 +271,7 @@ fn (m Module) remove() ! { } verbose_println('Removed `${m.name}`.') } + +fn at_version(version string) string { + return if version != '' { '@${version}' } else { '' } +} diff --git a/cmd/tools/vpm/install_test.v b/cmd/tools/vpm/install_test.v index 56f782089ecff7..99d71dcac29706 100644 --- a/cmd/tools/vpm/install_test.v +++ b/cmd/tools/vpm/install_test.v @@ -24,7 +24,7 @@ fn testsuite_end() { fn test_install_from_vpm_ident() { res := os.execute_or_exit('${v} install nedpals.args') - assert res.output.contains('Skipping download count increment for `nedpals.args`.') + assert res.output.contains('Skipping download count increment for `nedpals.args`.'), res.output mod := vmod.from_file(os.join_path(test_path, 'nedpals', 'args', 'v.mod')) or { assert false, err.msg() return @@ -45,7 +45,7 @@ fn test_install_from_vpm_short_ident() { fn test_install_from_git_url() { res := os.execute_or_exit('${v} install https://github.com/vlang/markdown') - assert res.output.contains('Installing module `markdown` from `https://github.com/vlang/markdown`') + assert res.output.contains('Installing `markdown`'), res.output mod := vmod.from_file(os.join_path(test_path, 'markdown', 'v.mod')) or { assert false, err.msg() return @@ -86,7 +86,7 @@ fn test_install_once() { install_cmd := '${@VEXE} install https://github.com/vlang/markdown https://github.com/vlang/pcre --once -v' // Try installing two modules, one of which is already installed. mut res := os.execute_or_exit(install_cmd) - assert res.output.contains("Already installed modules: ['markdown']") + assert res.output.contains("Already installed modules: ['markdown']"), res.output mod := vmod.from_file(os.join_path(test_path, 'pcre', 'v.mod')) or { assert false, err.msg() return @@ -99,7 +99,7 @@ fn test_install_once() { // Try installing two modules that are both already installed. res = os.execute_or_exit(install_cmd) - assert res.output.contains('All modules are already installed.') + assert res.output.contains('All modules are already installed.'), res.output assert md_last_modified == os.file_last_mod_unix(os.join_path(test_path, 'markdown', 'v.mod')) } @@ -108,13 +108,13 @@ fn test_missing_repo_name_in_url() { incomplete_url := 'https://github.com/vlang' res := os.execute('${v} install ${incomplete_url}') assert res.exit_code == 1 - assert res.output.contains('failed to retrieve module name for `${incomplete_url}`') + assert res.output.contains('failed to retrieve module name for `${incomplete_url}`'), res.output } fn test_missing_vmod_in_url() { - assert has_vmod('https://github.com/vlang/v') // head branch == `master`. - assert has_vmod('https://github.com/v-analyzer/v-analyzer') // head branch == `main`. - assert !has_vmod('https://github.com/octocat/octocat.github.io') // not a V module. + assert has_vmod('https://github.com/vlang/v', '') // head branch == `master`. + assert has_vmod('https://github.com/v-analyzer/v-analyzer', '') // head branch == `main`. + assert !has_vmod('https://github.com/octocat/octocat.github.io', '') // not a V module. res := os.execute('${v} install https://github.com/octocat/octocat.github.io') assert res.exit_code == 1 assert res.output.contains('failed to find `v.mod` for `https://github.com/octocat/octocat.github.io`'), res.output diff --git a/cmd/tools/vpm/install_version_test.v b/cmd/tools/vpm/install_version_test.v new file mode 100644 index 00000000000000..91cd16a588d635 --- /dev/null +++ b/cmd/tools/vpm/install_version_test.v @@ -0,0 +1,95 @@ +// vtest flaky: true +// vtest retry: 3 +module main + +import os +import v.vmod + +const ( + v = os.quoted_path(@VEXE) + test_path = os.join_path(os.vtmp_dir(), 'vpm_install_version_test') +) + +fn testsuite_begin() { + os.setenv('VMODULES', test_path, true) + os.setenv('VPM_DEBUG', '', true) + os.setenv('VPM_NO_INCREMENT', '1', true) +} + +fn testsuite_end() { + os.rmdir_all(test_path) or {} +} + +fn get_mod_name_and_version(path string) (string, string) { + mod := vmod.from_file(os.join_path(test_path, path, 'v.mod')) or { + eprintln(err) + return '', '' + } + return mod.name, mod.version +} + +fn test_install_from_vpm_with_git_version_tag() { + ident := 'ttytm.webview' + mut tag := 'v0.6.0' + mut res := os.execute_or_exit('${v} install ${ident}@${tag}') + assert res.output.contains('Installing `${ident}`'), res.output + assert res.output.contains('Installed `${ident}`'), res.output + mut name, mut version := get_mod_name_and_version(os.join_path('ttytm', 'webview')) + assert name == 'webview' + assert version == '0.6.0' + // Install same version without force flag. + res = os.execute_or_exit('${v} install ${ident}@${tag}') + assert res.output.contains('Module `${ident}@${tag}` is already installed, use --force to overwrite'), res.output + // Install another version, add force flag to surpass confirmation. + tag = 'v0.5.0' + res = os.execute_or_exit('${v} install -f ${ident}@${tag}') + assert res.output.contains('Installed `${ident}`'), res.output + name, version = get_mod_name_and_version(os.join_path('ttytm', 'webview')) + assert name == 'webview' + assert version == '0.5.0' + // Install invalid version. + tag = '6.0' + res = os.execute('${v} install -f ${ident}@${tag}') + assert res.exit_code == 1 + assert res.output.contains('failed to install `${ident}`'), res.output + // Install invalid version verbose. + res = os.execute('${v} install -f -v ${ident}@${tag}') + assert res.exit_code == 1 + assert res.output.contains('failed to install `${ident}`'), res.output + assert res.output.contains('Remote branch 6.0 not found in upstream origin'), res.output + // Install without version tag after a version was installed + res = os.execute_or_exit('${v} install -f ${ident}') + assert res.output.contains('Installing `${ident}`'), res.output + // Re-install latest version (without a tag). Should trigger an update, force should not be required. + res = os.execute_or_exit('${v} install ${ident}') + assert res.output.contains('Updating module `${ident}`'), res.output +} + +fn test_install_from_url_with_git_version_tag() { + url := 'https://github.com/vlang/vsl' + mut tag := 'v0.1.50' + mut res := os.execute_or_exit('v install ${url}@${tag}') + assert res.output.contains('Installing `vsl`'), res.output + assert res.output.contains('Installed `vsl`'), res.output + mut name, mut version := get_mod_name_and_version('vsl') + assert name == 'vsl' + assert version == '0.1.50' + // Install same version without force flag. + res = os.execute_or_exit('${v} install ${url}@${tag}') + assert res.output.contains('Module `vsl@${tag}` is already installed, use --force to overwrite'), res.output + // Install another version, add force flag to surpass confirmation. + tag = 'v0.1.47' + res = os.execute_or_exit('${v} install -f ${url}@${tag}') + assert res.output.contains('Installed `vsl`'), res.output + name, version = get_mod_name_and_version('vsl') + assert name == 'vsl' + assert version == '0.1.47' + // Install invalid version. + tag = 'abc' + res = os.execute('${v} install -f ${url}@${tag}') + assert res.exit_code == 1 + // Install invalid version verbose. + res = os.execute('${v} install -f -v ${url}@${tag}') + assert res.exit_code == 1 + assert res.output.contains('Remote branch abc not found in upstream origin'), res.output +} diff --git a/cmd/tools/vpm/settings.v b/cmd/tools/vpm/settings.v index 77114ade433eb5..2e7813c0ccdc4a 100644 --- a/cmd/tools/vpm/settings.v +++ b/cmd/tools/vpm/settings.v @@ -9,6 +9,7 @@ mut: is_help bool is_once bool is_verbose bool + is_force bool server_urls []string vcs string vmodules_path string @@ -26,6 +27,7 @@ fn init_settings() VpmSettings { is_help: '-h' in opts || '--help' in opts || 'help' in cmds is_once: '--once' in opts is_verbose: '-v' in opts + is_force: '-f' in opts || '--force' in opts vcs: if '--hg' in opts { 'hg' } else { 'git' } server_urls: cmdline.options(args, '--server-urls') vmodules_path: os.vmodules_dir()