Repository: gohugoio/hugo (language: go)

Issue:
Cascade deletion of  title and similar not handled very well on  partial rebuilds
I noticed this when looking into  #12436.

* In #12436 I noticed that we do a little too much work when a `_index.md` changes; we also re-reads all the bundles in that folder, which should not be needed.
* When adjusting that logic, I get a test failure from some cascade edits that I would say accidently passed because of the above.


Discussion / hints:

Fix patch:
diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go
index a4546ff4492..c96629cd6fd 100644
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -277,7 +277,7 @@ func (h *HugoSites) assemble(ctx context.Context, l logg.LevelLogger, bcfg *Buil
 
 	changes := assembleChanges.Changes()
 
-	// Changes from the assemble step (e.g. lastMod, cascase) needs a re-calculation
+	// Changes from the assemble step (e.g. lastMod, cascade) needs a re-calculation
 	// of what needs to be re-built.
 	if len(changes) > 0 {
 		if err := h.resolveAndClearStateForIdentities(ctx, l, nil, changes); err != nil {
@@ -598,6 +598,10 @@ type pathChange struct {
 	isDir bool
 }
 
+func (p pathChange) isStructuralChange() bool {
+	return p.delete || p.isDir
+}
+
 // processPartial prepares the Sites' sources for a partial rebuild.
 func (h *HugoSites) processPartial(ctx context.Context, l logg.LevelLogger, config *BuildCfg, init func(config *BuildCfg) error, events []fsnotify.Event) error {
 	h.Log.Trace(logg.StringFunc(func() string {
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index d8203fe751f..a88fe528d83 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -74,7 +74,9 @@ type pageMeta struct {
 // Prepare for a rebuild of the data passed in from front matter.
 func (m *pageMeta) setMetaPostPrepareRebuild() {
 	params := xmaps.Clone[map[string]any](m.paramsOriginal)
-	m.pageMetaParams.pageConfig.Params = params
+	m.pageMetaParams.pageConfig = &pagemeta.PageConfig{
+		Params: params,
+	}
 	m.pageMetaFrontMatter = pageMetaFrontMatter{}
 }
 
@@ -275,6 +277,7 @@ func (p *pageMeta) Weight() int {
 
 func (p *pageMeta) setMetaPre(pi *contentParseInfo, logger loggers.Logger, conf config.AllProvider) error {
 	frontmatter := pi.frontMatter
+
 	if frontmatter != nil {
 		pcfg := p.pageConfig
 		if pcfg == nil {
@@ -362,6 +365,7 @@ func (ps *pageState) setMetaPost(cascade map[page.PageMatcher]maps.Params) error
 	if ps.m.setMetaPostCount > 1 {
 		ps.m.setMetaPostCascadeChanged = cascadeHashPre != identity.HashUint64(ps.m.pageConfig.Cascade)
 		if !ps.m.setMetaPostCascadeChanged {
+
 			// No changes, restore any value that may be changed by aggregation.
 			ps.m.pageConfig.Dates = ps.m.datesOriginal
 			return nil
diff --git a/hugolib/pages_capture.go b/hugolib/pages_capture.go
index 231c2efad15..1633feb3ed2 100644
--- a/hugolib/pages_capture.go
+++ b/hugolib/pages_capture.go
@@ -147,7 +147,7 @@ func (c *pagesCollector) Collect() (collectErr error) {
 					false,
 					func(fim hugofs.FileMetaInfo) bool {
 						if fim.IsDir() {
-							return true
+							return id.isStructuralChange()
 						}
 						fimp := fim.Meta().PathInfo
 						if fimp == nil {
@@ -160,7 +160,7 @@ func (c *pagesCollector) Collect() (collectErr error) {
 			} else {
 				// We always start from a directory.
 				collectErr = c.collectDir(id.p, id.isDir, func(fim hugofs.FileMetaInfo) bool {
-					if id.delete || id.isDir {
+					if id.isStructuralChange() {
 						if id.isDir && fim.Meta().PathInfo.IsLeafBundle() {
 							return strings.HasPrefix(fim.Meta().PathInfo.Path(), paths.AddTrailingSlash(id.p.Path()))
 						}


Test patch:
diff --git a/hugolib/cascade_test.go b/hugolib/cascade_test.go
index 8075f190f4e..7a4b6e6be68 100644
--- a/hugolib/cascade_test.go
+++ b/hugolib/cascade_test.go
@@ -329,7 +329,7 @@ cascade:
 
 		counters := &buildCounters{}
 		b.Build(BuildCfg{testCounters: counters})
-		b.Assert(int(counters.contentRenderCounter.Load()), qt.Equals, 2)
+		b.Assert(int(counters.contentRenderCounter.Load()), qt.Equals, 1)
 
 		b.AssertFileContent("public/post/index.html", `Banner: post.jpg|Layout: postlayout|Type: posttype|Content: <p>content edit</p>`)
 		b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg|Layout: postlayout|`)
@@ -672,6 +672,55 @@ S1|p1:|p2:p2|
 	})
 }
 
+func TestCascadeEditIssue12449(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+baseURL = "https://example.com"
+disableKinds = ['sitemap','rss', 'home', 'taxonomy','term']
+disableLiveReload = true
+-- layouts/_default/list.html --
+Title: {{ .Title }}|{{ .Content }}|cascadeparam: {{ .Params.cascadeparam }}|
+-- layouts/_default/single.html --
+Title: {{ .Title }}|{{ .Content }}|cascadeparam: {{ .Params.cascadeparam }}|
+-- content/mysect/_index.md --
+---
+title: mysect
+cascade:
+  description: descriptionvalue
+  params:
+    cascadeparam: cascadeparamvalue
+---
+mysect-content|
+-- content/mysect/p1/index.md --
+---
+slug: p1
+---
+p1-content|
+-- content/mysect/subsect/_index.md --
+---
+slug: subsect
+---
+subsect-content|
+`
+
+	b := TestRunning(t, files)
+
+	// Make the cascade set the title.
+	b.EditFileReplaceAll("content/mysect/_index.md", "description: descriptionvalue", "title: cascadetitle").Build()
+	b.AssertFileContent("public/mysect/subsect/index.html", "Title: cascadetitle|")
+
+	// Edit cascade title.
+	b.EditFileReplaceAll("content/mysect/_index.md", "title: cascadetitle", "title: cascadetitle-edit").Build()
+	b.AssertFileContent("public/mysect/subsect/index.html", "Title: cascadetitle-edit|")
+
+	// Revert title change.
+	// The step below failed in #12449.
+	b.EditFileReplaceAll("content/mysect/_index.md", "title: cascadetitle-edit", "description: descriptionvalue").Build()
+	b.AssertFileContent("public/mysect/subsect/index.html", "Title: |")
+}
+
 // Issue 11977.
 func TestCascadeExtensionInPath(t *testing.T) {
 	t.Parallel()
diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go
index a847af3afc9..542810b64f6 100644
--- a/hugolib/rebuild_test.go
+++ b/hugolib/rebuild_test.go
@@ -1614,9 +1614,11 @@ p1-content|
 
 	b.AssertFileContent("public/index.html", "home-content|")
 	b.AssertFileContent("public/p1/index.html", "p1-content|")
+	b.AssertRenderCountPage(3)
 
 	b.EditFileReplaceAll("content/_index.md", "home-content", "home-content-foo").Build()
 	b.AssertFileContent("public/index.html", "home-content-foo")
+	b.AssertRenderCountPage(2) // Home page rss + html
 
 	b.EditFileReplaceAll("content/p1/index.md", "p1-content", "p1-content-foo").Build()
 	b.AssertFileContent("public/p1/index.html", "p1-content-foo")