p := commonTestScriptsParam
p.Dir = "testscripts/unfinished"
// p.UpdateScripts = true
+ // p.TestWork = true
testscript.Run(t, p)
}
}
time.Sleep(time.Duration(i) * time.Second)
},
+ // tree lists a directory recursively to stdout as a simple tree.
+ "tree": func(ts *testscript.TestScript, neg bool, args []string) {
+ dirname := ts.MkAbs(args[0])
+
+ err := filepath.WalkDir(dirname, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(dirname, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ fmt.Fprintln(ts.Stdout(), ".")
+ return nil
+ }
+ depth := strings.Count(rel, string(os.PathSeparator))
+ prefix := strings.Repeat(" ", depth) + "└─"
+ if d.IsDir() {
+ fmt.Fprintf(ts.Stdout(), "%s%s/\n", prefix, d.Name())
+ } else {
+ fmt.Fprintf(ts.Stdout(), "%s%s\n", prefix, d.Name())
+ }
+ return nil
+ })
+ if err != nil {
+ ts.Fatalf("%v", err)
+ }
+ },
// ls lists a directory to stdout.
"ls": func(ts *testscript.TestScript, neg bool, args []string) {
dirname := ts.MkAbs(args[0])
return
}
for _, fi := range fis {
- fmt.Fprintf(ts.Stdout(), "%s %04o %s %s\n", fi.Mode(), fi.Mode().Perm(), fi.ModTime().Format(time.RFC3339Nano), fi.Name())
+ fmt.Fprintf(ts.Stdout(), "%s %04o %s %s\n", fi.Mode(), fi.Mode().Perm(), fi.ModTime().Format(time.RFC3339), fi.Name())
+ }
+ },
+ // lsr lists a directory recursively to stdout.
+ "lsr": func(ts *testscript.TestScript, neg bool, args []string) {
+ dirname := ts.MkAbs(args[0])
+
+ err := filepath.WalkDir(dirname, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ fi, err := d.Info()
+ if err != nil {
+ return err
+ }
+
+ rel, err := filepath.Rel(dirname, path)
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprintf(ts.Stdout(), "%s %04o %s\n", fi.Mode(), fi.Mode().Perm(), filepath.ToSlash(rel))
+ return nil
+ })
+ if err != nil {
+ ts.Fatalf("%v", err)
}
},
// append appends to a file with a leading newline.
"os/exec"
"path/filepath"
"regexp"
+ "sort"
"strings"
"time"
const (
goModFilename = "go.mod"
goSumFilename = "go.sum"
+
+ // Checksum file for direct dependencies only,
+ // that is, module imports with version set.
+ hugoDirectSumFilename = "hugo.direct.sum"
)
// NewClient creates a new Client that can be used to manage the Hugo Components
continue
}
- if !c.shouldVendor(t.Path()) {
+ if c.shouldNotVendor(t.PathVersionQuery(false)) || c.shouldNotVendor(t.PathVersionQuery(true)) {
continue
}
// See https://github.com/gohugoio/hugo/issues/8239
// This is an error situation. We need something to vendor.
if t.Mounts() == nil {
- return fmt.Errorf("cannot vendor module %q, need at least one mount", t.Path())
+ return fmt.Errorf("cannot vendor module %q, need at least one mount", t.PathVersionQuery(false))
}
- fmt.Fprintln(&modulesContent, "# "+t.Path()+" "+t.Version())
+ fmt.Fprintln(&modulesContent, "# "+t.PathVersionQuery(true)+" "+t.Version())
dir := t.Dir()
for _, mount := range t.Mounts() {
sourceFilename := filepath.Join(dir, mount.Source)
- targetFilename := filepath.Join(vendorDir, t.Path(), mount.Source)
+ targetFilename := filepath.Join(vendorDir, t.PathVersionQuery(true), mount.Source)
fi, err := c.fs.Stat(sourceFilename)
if err != nil {
return fmt.Errorf("failed to vendor module: %w", err)
resourcesDir := filepath.Join(dir, files.FolderResources)
_, err := c.fs.Stat(resourcesDir)
if err == nil {
- if err := hugio.CopyDir(c.fs, resourcesDir, filepath.Join(vendorDir, t.Path(), files.FolderResources), nil); err != nil {
+ if err := hugio.CopyDir(c.fs, resourcesDir, filepath.Join(vendorDir, t.PathVersionQuery(true), files.FolderResources), nil); err != nil {
return fmt.Errorf("failed to copy resources to vendor dir: %w", err)
}
}
configDir := filepath.Join(dir, "config")
_, err = c.fs.Stat(configDir)
if err == nil {
- if err := hugio.CopyDir(c.fs, configDir, filepath.Join(vendorDir, t.Path(), "config"), nil); err != nil {
+ if err := hugio.CopyDir(c.fs, configDir, filepath.Join(vendorDir, t.PathVersionQuery(true), "config"), nil); err != nil {
return fmt.Errorf("failed to copy config dir to vendor dir: %w", err)
}
}
configFiles = append(configFiles, configFiles2...)
configFiles = append(configFiles, filepath.Join(dir, "theme.toml"))
for _, configFile := range configFiles {
- if err := hugio.CopyFile(c.fs, configFile, filepath.Join(vendorDir, t.Path(), filepath.Base(configFile))); err != nil {
+ if err := hugio.CopyFile(c.fs, configFile, filepath.Join(vendorDir, t.PathVersionQuery(true), filepath.Base(configFile))); err != nil {
if !herrors.IsNotExist(err) {
return err
}
return module.CheckPath(path) == nil
}
+func (c *Client) downloadModuleVersion(path, version string) (*goModule, error) {
+ args := []string{"mod", "download", "-json", fmt.Sprintf("%s@%s", path, version)}
+ b := &bytes.Buffer{}
+
+ err := c.runGo(context.Background(), b, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to download module %s@%s: %w", path, version, err)
+ }
+
+ m := &goModule{}
+ if err := json.NewDecoder(b).Decode(m); err != nil {
+ return nil, fmt.Errorf("failed to decode module download result: %w", err)
+ }
+
+ if m.Error != nil {
+ return nil, errors.New(m.Error.Err)
+ }
+
+ return m, nil
+}
+
func (c *Client) listGoMods() (goModules, error) {
if c.GoModulesFilename == "" || !c.moduleConfig.hasModuleImport() {
return nil, nil
}
downloadModules := func(modules ...string) error {
- args := []string{"mod", "download", "-modcacherw"}
+ args := []string{"mod", "download"}
args = append(args, modules...)
out := io.Discard
err := c.runGo(context.Background(), out, args...)
return modules, err
}
+func (c *Client) writeHugoDirectSum(mods Modules) error {
+ if c.GoModulesFilename == "" {
+ return nil
+ }
+ var sums []modSum
+ for _, m := range mods {
+ if m.Owner() == nil {
+ // This is the project.
+ continue
+ }
+ if m.IsGoMod() && m.VersionQuery() != "" {
+ sums = append(sums, modSum{pathVersionKey: pathVersionKey{path: m.Path(), version: m.Version()}, sum: m.Sum()})
+ }
+ }
+
+ // Read the existing sums.
+ existingSums, err := c.readModSumFile(hugoDirectSumFilename)
+ if err != nil {
+ return err
+ }
+ if len(sums) == 0 && len(existingSums) == 0 {
+ // Nothing to do.
+ return nil
+ }
+
+ dirty := len(sums) != len(existingSums)
+ for _, s1 := range sums {
+ if s2, ok := existingSums[s1.pathVersionKey]; ok {
+ if s1.sum != s2 {
+ return fmt.Errorf("verifying %s@%s: checksum mismatch: %s != %s", s1.path, s1.version, s1.sum, s2)
+ }
+ } else if !dirty {
+ dirty = true
+ }
+ }
+ if !dirty {
+ // Nothing changed.
+ return nil
+ }
+
+ // Write the sums file.
+ // First sort the sums for reproducible output.
+ sort.Slice(sums, func(i, j int) bool {
+ pvi, pvj := sums[i].pathVersionKey, sums[j].pathVersionKey
+ return pvi.path < pvj.path || (pvi.path == pvj.path && pvi.version < pvj.version)
+ })
+
+ f, err := c.fs.OpenFile(filepath.Join(c.ccfg.WorkingDir, hugoDirectSumFilename), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ for _, s := range sums {
+ fmt.Fprintf(f, "%s %s %s\n", s.pathVersionKey.path, s.pathVersionKey.version, s.sum)
+ }
+
+ return nil
+}
+
+func (c *Client) readModSumFile(filename string) (map[pathVersionKey]string, error) {
+ b, err := afero.ReadFile(c.fs, filepath.Join(c.ccfg.WorkingDir, filename))
+ if err != nil {
+ if herrors.IsNotExist(err) {
+ return make(map[pathVersionKey]string), nil
+ }
+ return nil, err
+ }
+ lines := bytes.Split(b, []byte{'\n'})
+ sums := make(map[pathVersionKey]string)
+ for _, line := range lines {
+ parts := bytes.Fields(line)
+ if len(parts) == 3 {
+ sums[pathVersionKey{path: string(parts[0]), version: string(parts[1])}] = string(parts[2])
+ }
+ }
+
+ return sums, nil
+}
+
func (c *Client) rewriteGoMod(name string, isGoMod map[string]bool) error {
data, err := c.rewriteGoModRewrite(name, isGoMod)
if err != nil {
return nil
}
-func (c *Client) shouldVendor(path string) bool {
- return c.noVendor == nil || !c.noVendor.Match(path)
+func (c *Client) shouldNotVendor(path string) bool {
+ return c.noVendor != nil && c.noVendor.Match(path)
}
func (c *Client) createThemeDirname(modulePath string, isProjectMod bool) (string, error) {
keyVals := []string{
"PWD", cfg.WorkingDir,
"GO111MODULE", "on",
+ "GOFLAGS", "-modcacherw",
"GOPATH", cfg.CacheDir,
"GOWORK", mcfg.Workspace, // Requires Go 1.18, see https://tip.golang.org/doc/go1.18
// GOCACHE was introduced in Go 1.15. This matches the location derived from GOPATH above.
Replace *goModule // replaced by this module
Time *time.Time // time version was created
Update *goModule // available update, if any (with -u)
+ Sum string // checksum
Main bool // is this the main module?
Indirect bool // is this module only an indirect dependency of main module?
Dir string // directory holding files for this module, if any
type goModules []*goModule
+type modSum struct {
+ pathVersionKey
+ sum string
+}
+
func (modules goModules) GetByPath(p string) *goModule {
if modules == nil {
return nil
env := ccfg.toEnv()
- c.Assert(env, qt.DeepEquals, []string{"PWD=/mywork", "GO111MODULE=on", "GOPATH=/mycache", "GOWORK=", filepath.FromSlash("GOCACHE=/mycache/pkg/mod")})
+ c.Assert(env, qt.DeepEquals, []string{"PWD=/mywork", "GO111MODULE=on", "GOFLAGS=-modcacherw", "GOPATH=/mycache", "GOWORK=", filepath.FromSlash("GOCACHE=/mycache/pkg/mod")})
ccfg = ClientConfig{
WorkingDir: "/mywork",
c.Assert(env, qt.DeepEquals, []string{
"PWD=/mywork",
"GO111MODULE=on",
+ "GOFLAGS=-modcacherw",
"GOPATH=/mycache",
"GOWORK=myworkspace",
filepath.FromSlash("GOCACHE=/mycache/pkg/mod"),
-// Copyright 2019 The Hugo Authors. All rights reserved.
+// Copyright 2025 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
"errors"
"fmt"
"io/fs"
+ "net/url"
"os"
"path/filepath"
"regexp"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/paths"
+ "golang.org/x/mod/module"
"github.com/spf13/cast"
"github.com/gohugoio/hugo/hugofs/files"
- "golang.org/x/mod/module"
-
"github.com/gohugoio/hugo/config"
"github.com/spf13/afero"
)
return tmp
}
+type pathVersionKey struct {
+ path string
+ version string
+}
+
type collected struct {
// Pick the first and prevent circular loops.
- seen map[string]bool
+ seenPaths map[string]*moduleAdapter
// Maps module path to a _vendor dir. These values are fetched from
// _vendor/modules.txt, and the first (top-most) will win.
func (c *collector) initModules() error {
c.collected = &collected{
- seen: make(map[string]bool),
- vendored: make(map[string]vendoredModule),
- gomods: goModules{},
+ seenPaths: make(map[string]*moduleAdapter),
+ vendored: make(map[string]vendoredModule),
+ gomods: goModules{},
}
// If both these are true, we don't even need Go installed to build.
return c.loadModules()
}
-func (c *collector) isSeen(path string) bool {
- key := pathKey(path)
- if c.seen[key] {
- return true
+func (c *collector) isPathSeen(p string, owner *moduleAdapter) *moduleAdapter {
+ // Remove any major version suffix.
+ // We do allow multiple major versions in the same project,
+ // but not as transitive dependencies.
+ p = pathBase(p)
+ if v, ok := c.seenPaths[p]; ok {
+ return v
}
- c.seen[key] = true
- return false
+ c.seenPaths[p] = owner
+ return nil
}
func (c *collector) getVendoredDir(path string) (vendoredModule, bool) {
return v, found
}
-func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapter, error) {
+func (c *collector) getAndCreateModule(owner *moduleAdapter, moduleImport Import) (*moduleAdapter, error) {
var (
- mod *goModule
- moduleDir string
- version string
- vendored bool
+ mod *goModule
+ moduleDir string
+ versionMod string
+ requestedVersionQuery string = moduleImport.Version
+ vendored bool
)
modulePath := moduleImport.Path
+ vendorPath := modulePath
+ vendorPathEscaped := modulePath
+ if requestedVersionQuery != "" {
+ vendorPath += "@" + requestedVersionQuery
+ vendorPathEscaped += "@" + url.QueryEscape(requestedVersionQuery)
+ }
+
var realOwner Module = owner
- if !c.ccfg.shouldIgnoreVendor(modulePath) {
+ if !(c.ccfg.shouldIgnoreVendor(vendorPath)) {
if err := c.collectModulesTXT(owner); err != nil {
return nil, err
}
// Try _vendor first.
var vm vendoredModule
- vm, vendored = c.getVendoredDir(modulePath)
+ vm, vendored = c.getVendoredDir(vendorPathEscaped)
if vendored {
moduleDir = vm.Dir
realOwner = vm.Owner
- version = vm.Version
+ versionMod = vm.Version
if owner.projectMod {
// We want to keep the go.mod intact with the versions and all.
}
if moduleDir == "" {
- var versionQuery string
- mod = c.gomods.GetByPath(modulePath)
- if mod != nil {
- moduleDir = mod.Dir
- versionQuery = mod.Version
+ if requestedVersionQuery == "" {
+ mod = c.gomods.GetByPath(modulePath)
+ if mod != nil {
+ moduleDir = mod.Dir
+ }
}
if moduleDir == "" {
- if c.GoModulesFilename != "" && isProbablyModule(modulePath) {
- // Try to "go get" it and reload the module configuration.
- if versionQuery == "" {
+ if isProbablyModule(modulePath) {
+ if requestedVersionQuery != "" {
+ var err error
+ mod, err = c.downloadModuleVersion(modulePath, requestedVersionQuery)
+ if err != nil {
+ return nil, err
+ }
+ if mod == nil {
+ return nil, fmt.Errorf("module %q not found", modulePath)
+ }
+ moduleDir = mod.Dir
+ versionMod = mod.Version
+ } else if c.GoModulesFilename != "" {
// See https://golang.org/ref/mod#version-queries
// This will select the latest release-version (not beta etc.).
- versionQuery = "upgrade"
- }
-
- // Note that we cannot use c.Get for this, as that may
- // trigger a new module collection and potentially create a infinite loop.
- if err := c.get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil {
- return nil, err
- }
- if err := c.loadModules(); err != nil {
- return nil, err
- }
-
- mod = c.gomods.GetByPath(modulePath)
- if mod != nil {
- moduleDir = mod.Dir
+ const versionQuery = "upgrade"
+ // Try to "go get" it and reload the module configuration.
+
+ // Note that we cannot use c.Get for this, as that may
+ // trigger a new module collection and potentially create a infinite loop.
+ if err := c.get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil {
+ return nil, err
+ }
+ if err := c.loadModules(); err != nil {
+ return nil, err
+ }
+
+ mod = c.gomods.GetByPath(modulePath)
+ if mod != nil {
+ moduleDir = mod.Dir
+ }
}
}
}
ma := &moduleAdapter{
- dir: moduleDir,
- vendor: vendored,
- gomod: mod,
- version: version,
+ dir: moduleDir,
+ vendor: vendored,
+ gomod: mod,
+ version: versionMod,
+ versionQuery: requestedVersionQuery,
// This may be the owner of the _vendor dir
owner: realOwner,
}
return nil, err
}
- c.modules = append(c.modules, ma)
return ma, nil
}
return fmt.Errorf("failed to apply mounts for project: %w", err)
}
}
-
+ seen := make(map[pathVersionKey]bool)
for _, moduleImport := range moduleConfig.Imports {
if moduleImport.Disable {
continue
}
- if !c.isSeen(moduleImport.Path) {
- tc, err := c.add(owner, moduleImport)
- if err != nil {
- return err
- }
- if tc == nil || moduleImport.IgnoreImports {
- continue
- }
- if err := c.addAndRecurse(tc); err != nil {
- return err
+
+ // Prevent cyclic references.
+ if v := c.isPathSeen(moduleImport.Path, owner); v != nil && v != owner {
+ continue
+ }
+
+ tc, err := c.getAndCreateModule(owner, moduleImport)
+ if err != nil {
+ return err
+ }
+
+ if tc == nil {
+ continue
+ }
+
+ pk := pathVersionKey{path: tc.Path(), version: tc.Version()}
+ seenInCurrent := seen[pk]
+ if seenInCurrent {
+ // Only one import of the same module per project.
+ if owner.projectMod {
+ // In Hugo v0.150.0 we introduced direct dependencies, and it may be tempting to import the same version
+ // with different mount setups. We may allow that in the future, but we need to get some experience first.
+ // For now, we just warn. The user needs to add multiple mount points in the same import.
+ c.logger.Warnf("module with path %q is imported for the same version %q more than once", tc.Path(), tc.Version())
}
+ continue
}
+ seen[pk] = true
+
+ c.modules = append(c.modules, tc)
+
+ if moduleImport.IgnoreImports {
+ continue
+ }
+
+ if err := c.addAndRecurse(tc); err != nil {
+ return err
+ }
+
}
return nil
}
// Add the project mod on top.
c.modules = append(Modules{projectMod}, c.modules...)
+
+ if err := c.writeHugoDirectSum(c.modules); err != nil {
+ c.err = err
+ return
+ }
}
func (c *collector) isVendored(dir string) bool {
}
}
-// In the first iteration of Hugo Modules, we do not support multiple
-// major versions running at the same time, so we pick the first (upper most).
-// We will investigate namespaces in future versions.
-// TODO(bep) add a warning when the above happens.
-func pathKey(p string) string {
+func pathBase(p string) string {
prefix, _, _ := module.SplitPathVersion(p)
-
return strings.ToLower(prefix)
}
{"github.com/foo/v3d", "github.com/foo/v3d"},
{"MyTheme", "mytheme"},
} {
- c.Assert(pathKey(test.in), qt.Equals, test.expect)
+ c.Assert(pathBase(test.in), qt.Equals, test.expect)
}
}
type Import struct {
// Module path
Path string
+
+ // The common case is to leave this empty and let Go Modules resolve the version.
+ // Can be set to a version query, e.g. "v1.2.3", ">=v1.2.0", "latest", which will
+ // make this a direct dependency.
+ Version string
// Set when Path is replaced in project config.
pathProjectReplaced bool
// Ignore any config in config.toml (will still follow imports).
package modules
import (
+ "net/url"
"time"
"github.com/gohugoio/hugo/config"
// or the path below your /theme folder, e.g. "mytheme".
Path() string
+ // For direct dependencies, this will be Path + "@" + VersionQuery.
+ // For managed dependencies, this will be the same as Path.
+ PathVersionQuery(escapeQuery bool) string
+
// Replaced by this module.
Replace() Module
// The module version.
Version() string
+ // The version query requested in the import.
+ VersionQuery() string
+
+ // The expected cryptographic hash of the module.
+ Sum() string
+
// Time version was created.
Time() time.Time
type Modules []Module
type moduleAdapter struct {
- path string
- dir string
- version string
- vendor bool
- projectMod bool
- owner Module
+ path string
+ dir string
+ version string
+ versionQuery string
+ vendor bool
+ projectMod bool
+ owner Module
mounts []Mount
return m.gomod.Path
}
+func (m *moduleAdapter) PathVersionQuery(escapeQuery bool) string {
+ // We added version as a config option in Hugo v0.150.0, so
+ // to make this backward compatible, we only add the version
+ // if it was explicitly requested.
+ pathBase := m.Path()
+ if m.versionQuery == "" || !m.IsGoMod() {
+ return pathBase
+ }
+
+ q := m.versionQuery
+ if escapeQuery {
+ q = url.QueryEscape(q)
+ }
+
+ return pathBase + "@" + q
+}
+
func (m *moduleAdapter) Replace() Module {
if m.IsGoMod() && !m.Vendor() && m.gomod.Replace != nil {
return &moduleAdapter{
return m.gomod.Version
}
+func (m *moduleAdapter) VersionQuery() string {
+ return m.versionQuery
+}
+
+func (m *moduleAdapter) Sum() string {
+ if !m.IsGoMod() {
+ return ""
+ }
+
+ return m.gomod.Sum
+}
+
func (m *moduleAdapter) Time() time.Time {
if !m.IsGoMod() || m.gomod.Time == nil {
return time.Time{}
--- /dev/null
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package modules_test
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestModuleImportWithVersion(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.2.0"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.1.0"
+[[module.imports.mounts]]
+source = "content"
+target = "content/v1"
+-- layouts/all.html --
+Title: {{ .Title }}|Summary: {{ .Summary }}|
+Deps: {{ range hugo.Deps}}{{ printf "%s@%s" .Path .Version }}|{{ end }}$
+
+
+`
+
+ b := hugolib.Test(t, files, hugolib.TestOptWithOSFs()).Build()
+
+ b.AssertFileContent("public/index.html", "Deps: project@|github.com/bep/hugo-mod-misc/dummy-content@v0.2.0|github.com/bep/hugo-mod-misc/dummy-content@v0.1.0|$")
+
+ b.AssertFileContent("public/blog/music/autumn-leaves/index.html", "Autumn Leaves is a popular jazz standard") // v0.2.0
+ b.AssertFileContent("public/v1/blog/music/autumn-leaves/index.html", "Lorem markdownum, placidi peremptis") // v0.1.0
+}
--- /dev/null
+hugo mod graph
+stdout 'foo'
+grep 'github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD\+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4II=' hugo.direct.sum
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-nop"
+version = "v1.0.0"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
\ No newline at end of file
--- /dev/null
+! hugo
+stderr 'checksum mismatch'
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-nop"
+version = "v1.0.0"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
+-- hugo.direct.sum --
+github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4Ix=
\ No newline at end of file
--- /dev/null
+
+# We're not testing Go's security here, so we just edit the go.sum and verifies that the build fails.
+
+! hugo
+stderr 'checksum mismatch'
+
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-nop"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
+
+require github.com/bep/hugo-mod-nop v1.0.0 // indirect
+-- go.sum --
+github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4II=
+github.com/bep/hugo-mod-nop v1.0.0/go.mod h1:6cqjOCVJHP6+aQ+5IYvaS9j8BfIVksAct4xzl9OnmIX=
\ No newline at end of file
--- /dev/null
+dostounix golden/vendor.txt
+
+hugo mod vendor
+cmp _vendor/modules.txt golden/vendor.txt
+lsr _vendor
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/config.toml'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/config.toml'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/content/blog/music/all-of-me/index.md'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/content/blog/music/blue-bossa/index.md'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/content/blog/music/all-of-me/index.md'
+! stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/content/blog/music/blue-bossa/index.md' # not mounted
+
+hugo mod graph
+stdout 'project github.com/bep/hugo-mod-misc/dummy-content@v0.2.0'
+stdout 'project github.com/bep/hugo-mod-misc/dummy-content@v0.1.0'
+
+hugo config mounts
+[unix] cmpenv stdout golden/mounts.json
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.2.0"
+[[module.imports]]
+path = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "<=v0.1.0"
+[[module.imports.mounts]]
+source = "content/blog/music/all-of-me"
+target = "content/all"
+-- layouts/all.html --
+Title: {{ .Title }}|Summary: {{ .Summary }}|
+Deps: {{ range hugo.Deps}}{{ printf "%s@%s" .Path .Version }}|{{ end }}$
+
+
+-- golden/vendor.txt --
+# github.com/bep/hugo-mod-misc/dummy-content@v0.2.0 v0.2.0
+# github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0 v0.1.0
+-- golden/mounts.json --
+{
+ "path": "project",
+ "version": "",
+ "time": "0001-01-01T00:00:00Z",
+ "owner": "",
+ "dir": "$WORK",
+ "mounts": [
+ {
+ "source": "content",
+ "target": "content"
+ },
+ {
+ "source": "data",
+ "target": "data"
+ },
+ {
+ "source": "layouts",
+ "target": "layouts"
+ },
+ {
+ "source": "i18n",
+ "target": "i18n"
+ },
+ {
+ "source": "archetypes",
+ "target": "archetypes"
+ },
+ {
+ "source": "assets",
+ "target": "assets"
+ },
+ {
+ "source": "static",
+ "target": "static"
+ }
+ ]
+}
+{
+ "path": "github.com/bep/hugo-mod-misc/dummy-content",
+ "version": "v0.2.0",
+ "time": "0001-01-01T00:00:00Z",
+ "owner": "project",
+ "dir": "$WORK/_vendor/github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/",
+ "mounts": [
+ {
+ "source": "content",
+ "target": "content"
+ }
+ ]
+}
+{
+ "path": "github.com/bep/hugo-mod-misc/dummy-content",
+ "version": "v0.1.0",
+ "time": "0001-01-01T00:00:00Z",
+ "owner": "project",
+ "dir": "$WORK/_vendor/github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/",
+ "mounts": [
+ {
+ "source": "content/blog/music/all-of-me",
+ "target": "content/all"
+ }
+ ]
+}