diff --git a/internal/writer/footer.go b/internal/writer/footer.go index f0565a8..c8a5ab1 100644 --- a/internal/writer/footer.go +++ b/internal/writer/footer.go @@ -68,3 +68,23 @@ func whatIsBlock(opts FooterOptions) string { func HasMarker(content []byte) bool { return strings.Contains(string(content), MarkerStart) } + +// stripGeneratedMarker 把首个 `` 注释整体替换为稳定占位符 +// +// 用于 writer.Apply 内容等价性比较:marker 含 version + timestamp,每次 sync 都会变, +// 不归一化会导致即使源文档未变也整文件重写,污染 git diff。 +// +// 仅替换首个 MarkerStart 注释;MarkerEnd 不带变量值无需处理。 +func stripGeneratedMarker(content []byte) []byte { + s := string(content) + start := strings.Index(s, MarkerStart) + if start < 0 { + return content + } + rel := strings.Index(s[start:], "-->") + if rel < 0 { + return content + } + end := start + rel + len("-->") + return []byte(s[:start] + "" + s[end:]) +} diff --git a/internal/writer/writer.go b/internal/writer/writer.go index 6d6939e..ebcfdcb 100644 --- a/internal/writer/writer.go +++ b/internal/writer/writer.go @@ -38,6 +38,14 @@ func (w *Writer) Apply(plan *Plan) (written, skipped int, err error) { skipped++ continue } + // marker 含每次 sync 不同的 timestamp(footer.HeaderComment 注入 GeneratedAt), + // 归一化 marker 后再比一次:源文档未变则不重写,避免污染 git diff / mtime。 + if len(exist) > 0 && bytes.Equal(stripGeneratedMarker(exist), stripGeneratedMarker(op.Content)) { + op.Skip = true + op.Reason = "unchanged" + skipped++ + continue + } if w.DryRun { continue } diff --git a/internal/writer/writer_test.go b/internal/writer/writer_test.go index 1573d61..54cccad 100644 --- a/internal/writer/writer_test.go +++ b/internal/writer/writer_test.go @@ -246,6 +246,73 @@ func TestApplyMixedWrittenAndSkipped(t *testing.T) { } } +func TestApplySkipsWhenOnlyMarkerTimestampDiffers(t *testing.T) { + tmp := t.TempDir() + w := NewWriter(tmp, false) + + oldHeader := "\n" + newHeader := "\n" + body := "# Style\n\nbody content\n\n" + mustWrite(t, filepath.Join(tmp, "out.md"), oldHeader+body) + + statBefore, _ := os.Stat(filepath.Join(tmp, "out.md")) + + plan := &Plan{ + Files: []FileOp{ + {Path: "out.md", Content: []byte(newHeader + body), Marker: true}, + }, + } + written, skipped, err := w.Apply(plan) + if err != nil { + t.Fatal(err) + } + if written != 0 || skipped != 1 { + t.Errorf("written=%d skipped=%d, want 0/1", written, skipped) + } + if !plan.Files[0].Skip || plan.Files[0].Reason != "unchanged" { + t.Errorf("op = %+v", plan.Files[0]) + } + statAfter, _ := os.Stat(filepath.Join(tmp, "out.md")) + if !statBefore.ModTime().Equal(statAfter.ModTime()) { + t.Errorf("mtime should not change: before=%v after=%v", statBefore.ModTime(), statAfter.ModTime()) + } + got, _ := os.ReadFile(filepath.Join(tmp, "out.md")) + if string(got) != oldHeader+body { + t.Errorf("file content should be untouched, got %q", got) + } +} + +func TestApplyWritesWhenBodyChangesEvenIfMarkerSame(t *testing.T) { + tmp := t.TempDir() + w := NewWriter(tmp, false) + header := "\n" + mustWrite(t, filepath.Join(tmp, "out.md"), header+"# Old\n") + + plan := &Plan{ + Files: []FileOp{ + {Path: "out.md", Content: []byte(header + "# New\n"), Marker: true}, + }, + } + written, skipped, _ := w.Apply(plan) + if written != 1 || skipped != 0 { + t.Errorf("written=%d skipped=%d, want 1/0", written, skipped) + } +} + +func TestStripGeneratedMarker(t *testing.T) { + src := []byte("\nbody\n") + got := stripGeneratedMarker(src) + want := "\nbody\n" + if string(got) != want { + t.Errorf("stripGeneratedMarker = %q, want %q", got, want) + } + + plain := []byte("# no marker here\nbody\n") + if string(stripGeneratedMarker(plain)) != string(plain) { + t.Error("content without marker should be returned as-is") + } +} + func TestApplySkipPreSet(t *testing.T) { tmp := t.TempDir() w := NewWriter(tmp, false)