summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.go54
-rw-r--r--main_test.go34
2 files changed, 84 insertions, 4 deletions
diff --git a/main.go b/main.go
index ec081ba..c7fb8ce 100644
--- a/main.go
+++ b/main.go
@@ -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")
+ }
+}