Repository: gohugoio/hugo (language: go)

Issue:
Better handling of the `module.workspace` config
The current state of workspaces in Hugo is:
- `module.workspace` needs an absolute path
- With workspace, Hugo was able to resolve nested modules in a private Hugo theme (added as a module). A good alternative to https://github.com/gohugoio/hugo/issues/9494
- Hugo perfectly builds the site in workspaces, however, it doesn't watch the directories from `use` directive in `go.work` for changes. For example, my use case was to debug a theme I'm working on. Hugo would not detect and rebuild the site if I made changes to the theme.

My `go.work`:
```
go 1.19

use ../../themes/hugo-yami
```

It would be really great if Hugo looks for the `go.work` file relative to the project. Additionally, having Hugo parse the `go.work` file to look out for `use` directives would be awesome. It would allow site rebuilds if any of the modules in the workspace got updated. 


Discussion / hints:

Fix patch:
diff --git a/commands/hugo.go b/commands/hugo.go
index e247fca275a..d2d99c7fabc 100644
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -924,6 +924,7 @@ func (c *commandeer) printChangeDetected(typ string) {
 const (
 	configChangeConfig = "config file"
 	configChangeGoMod  = "go.mod file"
+	configChangeGoWork = "go work file"
 )
 
 func (c *commandeer) handleEvents(watcher *watcher.Batcher,
@@ -943,6 +944,9 @@ func (c *commandeer) handleEvents(watcher *watcher.Batcher,
 			if strings.Contains(ev.Name, "go.mod") {
 				configChangeType = configChangeGoMod
 			}
+			if strings.Contains(ev.Name, ".work") {
+				configChangeType = configChangeGoWork
+			}
 		}
 		if !isConfig {
 			// It may be one of the /config folders
diff --git a/docs/content/en/hugo-modules/configuration.md b/docs/content/en/hugo-modules/configuration.md
index c05201f8a88..5b19d2419f3 100644
--- a/docs/content/en/hugo-modules/configuration.md
+++ b/docs/content/en/hugo-modules/configuration.md
@@ -23,7 +23,7 @@ proxy = "direct"
 noProxy = "none"
 private = "*.*"
 replacements = ""
-workspace = ""
+workspace = "off"
 {{< /code-toggle >}}
 
 noVendor
@@ -42,7 +42,7 @@ private
 : Comma separated glob list matching paths that should be treated as private.
 
 workspace
-: The workspace file to use. This enables Go workspace mode. Note that this can also be set via OS env, e.g. `export HUGO_MODULE_WORKSPACE=/my/hugo.work` This only works with Go 1.18+.
+: The workspace file to use. This enables Go workspace mode. Note that this can also be set via OS env, e.g. `export HUGO_MODULE_WORKSPACE=/my/hugo.work` This only works with Go 1.18+. In Hugo `v0.109.0` we changed the default to `off` and we now resolve any relative work filenames relative to the working directory.
 
 replacements
 : A comma separated (or a slice) list of module path to directory replacement mapping, e.g. `github.com/bep/my-theme -> ../..,github.com/bep/shortcodes -> /some/path`. This is mostly useful for temporary locally development of a module, and then it makes sense to set it as an OS environment variable, e.g: `env HUGO_MODULE_REPLACEMENTS="github.com/bep/my-theme -> ../.."`. Any relative path is relate to [themesDir](https://gohugo.io/getting-started/configuration/#all-configuration-settings), and absolute paths are allowed.
diff --git a/hugolib/config.go b/hugolib/config.go
index 8444d15d916..9ccb8701480 100644
--- a/hugolib/config.go
+++ b/hugolib/config.go
@@ -417,12 +417,18 @@ func (l configLoader) collectModules(modConfig modules.Config, v1 config.Provide
 	// Avoid recreating these later.
 	v1.Set("allModules", moduleConfig.ActiveModules)
 
+	// We want to watch these for changes and trigger rebuild on version
+	// changes etc.
 	if moduleConfig.GoModulesFilename != "" {
-		// We want to watch this for changes and trigger rebuild on version
-		// changes etc.
+
 		configFilenames = append(configFilenames, moduleConfig.GoModulesFilename)
 	}
 
+	if moduleConfig.GoWorkspaceFilename != "" {
+		configFilenames = append(configFilenames, moduleConfig.GoWorkspaceFilename)
+
+	}
+
 	return moduleConfig.ActiveModules, configFilenames, err
 }
 
diff --git a/modules/collect.go b/modules/collect.go
index 7d92e30456e..ae6df9be9b2 100644
--- a/modules/collect.go
+++ b/modules/collect.go
@@ -107,9 +107,15 @@ func (h *Client) collect(tidy bool) (ModulesConfig, *collector) {
 		}
 	}*/
 
+	var workspaceFilename string
+	if h.ccfg.ModuleConfig.Workspace != WorkspaceDisabled {
+		workspaceFilename = h.ccfg.ModuleConfig.Workspace
+	}
+
 	return ModulesConfig{
-		AllModules:        c.modules,
-		GoModulesFilename: c.GoModulesFilename,
+		AllModules:          c.modules,
+		GoModulesFilename:   c.GoModulesFilename,
+		GoWorkspaceFilename: workspaceFilename,
 	}, c
 }
 
@@ -122,6 +128,9 @@ type ModulesConfig struct {
 
 	// Set if this is a Go modules enabled project.
 	GoModulesFilename string
+
+	// Set if a Go workspace file is configured.
+	GoWorkspaceFilename string
 }
 
 func (m *ModulesConfig) setActiveMods(logger loggers.Logger) error {
diff --git a/modules/config.go b/modules/config.go
index 08154bc110f..c08d2a4ab84 100644
--- a/modules/config.go
+++ b/modules/config.go
@@ -15,6 +15,7 @@ package modules
 
 import (
 	"fmt"
+	"os"
 	"path/filepath"
 	"strings"
 
@@ -26,6 +27,8 @@ import (
 	"github.com/mitchellh/mapstructure"
 )
 
+const WorkspaceDisabled = "off"
+
 var DefaultModuleConfig = Config{
 
 	// Default to direct, which means "git clone" and similar. We
@@ -41,6 +44,9 @@ var DefaultModuleConfig = Config{
 	// treated as private.
 	Private: "*.*",
 
+	// Default is no workspace resolution.
+	Workspace: WorkspaceDisabled,
+
 	// A list of replacement directives mapping a module path to a directory
 	// or a theme component in the themes folder.
 	// Note that this will turn the component into a traditional theme component
@@ -247,6 +253,19 @@ func decodeConfig(cfg config.Provider, pathReplacements map[string]string) (Conf
 			c.Mounts[i] = mnt
 		}
 
+		if c.Workspace == "" {
+			c.Workspace = WorkspaceDisabled
+		}
+		if c.Workspace != WorkspaceDisabled {
+			c.Workspace = filepath.Clean(c.Workspace)
+			if !filepath.IsAbs(c.Workspace) {
+				workingDir := cfg.GetString("workingDir")
+				c.Workspace = filepath.Join(workingDir, c.Workspace)
+			}
+			if _, err := os.Stat(c.Workspace); err != nil {
+				return c, fmt.Errorf("module workspace %q does not exist", c.Workspace)
+			}
+		}
 	}
 
 	if themeSet {
@@ -294,8 +313,9 @@ type Config struct {
 	// Configures GOPRIVATE.
 	Private string
 
-	// Set the workspace file to use, e.g. hugo.work.
-	// Enables Go "Workspace" mode.
+	// Defaults to "off".
+	// Set to a work file, e.g. hugo.work, to enable Go "Workspace" mode.
+	// Can be relative to the working directory or absolute.
 	// Requires Go 1.18+
 	// See https://tip.golang.org/doc/go1.18
 	Workspace string
diff --git a/modules/module.go b/modules/module.go
index 0d094fe87f7..42bd94e7be1 100644
--- a/modules/module.go
+++ b/modules/module.go
@@ -184,5 +184,7 @@ func (m *moduleAdapter) Watch() bool {
 		return m.Replace().Version() == ""
 	}
 
-	return false
+	// Any module set up in a workspace file will have Indirect set to false.
+	// That leaves modules inside the read-only module cache.
+	return !m.gomod.Indirect
 }


Test patch:
diff --git a/hugolib/hugo_sites_build_test.go b/hugolib/hugo_sites_build_test.go
index c31f9471304..d7e8a89c4d6 100644
--- a/hugolib/hugo_sites_build_test.go
+++ b/hugolib/hugo_sites_build_test.go
@@ -206,6 +206,7 @@ func TestMultiSitesBuild(t *testing.T) {
 		{multiSiteYAMLConfigTemplate, "yml"},
 		{multiSiteJSONConfigTemplate, "json"},
 	} {
+		config := config
 		t.Run(config.suffix, func(t *testing.T) {
 			t.Parallel()
 			doTestMultiSitesBuild(t, config.content, config.suffix)
diff --git a/hugolib/page_permalink_test.go b/hugolib/page_permalink_test.go
index 0939cc1ff51..7ea672330f4 100644
--- a/hugolib/page_permalink_test.go
+++ b/hugolib/page_permalink_test.go
@@ -63,6 +63,7 @@ func TestPermalink(t *testing.T) {
 	}
 
 	for i, test := range tests {
+		i := i
 		test := test
 		t.Run(fmt.Sprintf("%s-%d", test.file, i), func(t *testing.T) {
 			t.Parallel()
diff --git a/modules/config_test.go b/modules/config_test.go
index 371aab05617..5c97caaa2ba 100644
--- a/modules/config_test.go
+++ b/modules/config_test.go
@@ -14,6 +14,9 @@
 package modules
 
 import (
+	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/gohugoio/hugo/common/hugo"
@@ -43,14 +46,15 @@ func TestDecodeConfig(t *testing.T) {
 	c := qt.New(t)
 
 	c.Run("Basic", func(c *qt.C) {
+		tempDir := c.TempDir()
 		tomlConfig := `
+workingDir = %q
 [module]
-
+workspace = "hugo.work"
 [module.hugoVersion]
 min = "0.54.2"
 max = "0.199.0"
 extended = true
-
 [[module.mounts]]
 source="src/project/blog"
 target="content/blog"
@@ -65,7 +69,11 @@ source="src/markdown/blog"
 target="content/blog"
 lang="en"
 `
-		cfg, err := config.FromConfigString(tomlConfig, "toml")
+
+		hugoWorkFilename := filepath.Join(tempDir, "hugo.work")
+		f, _ := os.Create(hugoWorkFilename)
+		f.Close()
+		cfg, err := config.FromConfigString(fmt.Sprintf(tomlConfig, tempDir), "toml")
 		c.Assert(err, qt.IsNil)
 
 		mcfg, err := DecodeConfig(cfg)
@@ -83,6 +91,8 @@ lang="en"
 			c.Assert(hv.IsValid(), qt.Equals, true)
 		}
 
+		c.Assert(mcfg.Workspace, qt.Equals, hugoWorkFilename)
+
 		c.Assert(len(mcfg.Mounts), qt.Equals, 1)
 		c.Assert(len(mcfg.Imports), qt.Equals, 1)
 		imp := mcfg.Imports[0]
@@ -90,6 +100,7 @@ lang="en"
 		c.Assert(imp.Mounts[1].Source, qt.Equals, "src/markdown/blog")
 		c.Assert(imp.Mounts[1].Target, qt.Equals, "content/blog")
 		c.Assert(imp.Mounts[1].Lang, qt.Equals, "en")
+
 	})
 
 	c.Run("Replacements", func(c *qt.C) {