diff options
| -rw-r--r-- | main.go | 54 | ||||
| -rw-r--r-- | main_test.go | 34 |
2 files changed, 84 insertions, 4 deletions
@@ -17,6 +17,7 @@ import ( "slices" "sort" "strings" + "syscall" "time" "github.com/yuin/goldmark" @@ -707,7 +708,7 @@ func (s *site) write(outputs map[string][]byte, manageGitignore bool) error { from := filepath.Join(backup, filepath.FromSlash(movedOld[i])) to := filepath.Join(s.root, filepath.FromSlash(movedOld[i])) _ = os.MkdirAll(filepath.Dir(to), 0o755) - _ = os.Rename(from, to) + _ = moveFile(from, to) } } moveOld := func(rel string) error { @@ -721,7 +722,7 @@ func (s *site) write(outputs map[string][]byte, manageGitignore bool) error { if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { return err } - if err := os.Rename(from, to); err != nil { + if err := moveFile(from, to); err != nil { return err } movedOld = append(movedOld, rel) @@ -740,16 +741,28 @@ func (s *site) write(outputs map[string][]byte, manageGitignore bool) error { install = append(install, ".gitignore") } for _, rel := range install { + to := filepath.Join(s.root, filepath.FromSlash(rel)) + // Leave byte-identical outputs untouched so their mtimes (and thus + // Last-Modified/ETag headers) only change when content does. + if existing, err := os.ReadFile(to); err == nil { + staged, err := os.ReadFile(filepath.Join(stage, filepath.FromSlash(rel))) + if err != nil { + rollback() + return err + } + if bytes.Equal(existing, staged) { + continue + } + } if err := moveOld(rel); err != nil { rollback() return err } - to := filepath.Join(s.root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { rollback() return err } - if err := os.Rename(filepath.Join(stage, filepath.FromSlash(rel)), to); err != nil { + if err := moveFile(filepath.Join(stage, filepath.FromSlash(rel)), to); err != nil { rollback() return err } @@ -758,6 +771,39 @@ func (s *site) write(outputs map[string][]byte, manageGitignore bool) error { return nil } +// moveFile is os.Rename with a fallback for cross-device moves (EXDEV), which +// happen when part of the site tree is a symlink onto another filesystem: the +// contents are staged next to the destination and renamed into place there. +func moveFile(from, to string) error { + err := os.Rename(from, to) + if err == nil || !errors.Is(err, syscall.EXDEV) { + return err + } + contents, err := os.ReadFile(from) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(to), ".weft-move-") + if err != nil { + return err + } + _, werr := tmp.Write(contents) + if cerr := tmp.Close(); werr == nil { + werr = cerr + } + if werr == nil { + werr = os.Chmod(tmp.Name(), 0o644) + } + if werr == nil { + werr = os.Rename(tmp.Name(), to) + } + if werr != nil { + _ = os.Remove(tmp.Name()) + return werr + } + return os.Remove(from) +} + func (s *site) gitignore(outputs []string) ([]byte, error) { raw, err := os.ReadFile(filepath.Join(s.root, ".gitignore")) if err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/main_test.go b/main_test.go index c4b0ec7..f2aae0a 100644 --- a/main_test.go +++ b/main_test.go @@ -361,3 +361,37 @@ func TestSortingFilteringSlicing(t *testing.T) { t.Fatal("slice") } } + +func TestUnchangedOutputsKeepMtime(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "layout.tmpl", testLayouts()) + writeTestFile(t, root, "a.md", "# A\n\nbody\n") + writeTestFile(t, root, "b.md", "# B\n\nbody\n") + if err := build(root); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + for _, name := range []string{"a.html", "b.html"} { + if err := os.Chtimes(filepath.Join(root, name), old, old); err != nil { + t.Fatal(err) + } + } + writeTestFile(t, root, "b.md", "# B\n\nchanged\n") + if err := build(root); err != nil { + t.Fatal(err) + } + unchanged, err := os.Stat(filepath.Join(root, "a.html")) + if err != nil { + t.Fatal(err) + } + if !unchanged.ModTime().Equal(old) { + t.Errorf("unchanged output mtime bumped: %v", unchanged.ModTime()) + } + changed, err := os.Stat(filepath.Join(root, "b.html")) + if err != nil { + t.Fatal(err) + } + if changed.ModTime().Equal(old) { + t.Error("changed output mtime not updated") + } +} |
