From f58eec182db16998ff24dd255220f1507af87547 Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 14 Nov 2026 15:23:46 +0100 Subject: [PATCH] fix(DS-REL): date a re-added section from its last addition (v0.3.2) Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- RELEASE.md | 11 +++++ internal/gitinfo/added_test.go | 83 ++++++++++++++++++++++++++++++++++ internal/gitinfo/gitinfo.go | 19 ++++++-- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 internal/gitinfo/added_test.go diff --git a/README.md b/README.md index 8e78229..92de55a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ Exit codes: 0 when the documents agree with the code, 1 with findings; see The checks that need tags and history (DS-SEC's supported minor, DS-REL's untagged section) run only in a git work tree with its tags: check out with -`fetch-depth: 0` in CI. +`fetch-depth: 0` in CI. DS-REL dates the newest section from the last commit +that added its heading: a section that was folded away and written again dates +from the new commit, and one not committed yet has no date. Documents of history and plans are not checked: `RELEASE.md` (apart from DS-REL), `CHANGELOG.md`, `PLAN.md`, `ROADMAP.md`, `PROGRESS.md`, ADRs, diff --git a/RELEASE.md b/RELEASE.md index 19a4c81..bea2e76 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,16 @@ # docsync Releases +## v0.3.2 + +**Date:** 2026-11-14 +**Previous release:** v0.3.1 + +### Fixed + +- DS-REL dates an untagged newest section from the last commit that added its heading, not the first. A heading that was added, folded into another section and written again (go-iec61850's `## v1.21.0`) is no longer reported as days old in the pull request that writes it again. A heading that is back in the work tree but not committed has no date, as one never committed has none. + +--- + ## v0.3.1 **Date:** 2026-11-07 diff --git a/internal/gitinfo/added_test.go b/internal/gitinfo/added_test.go new file mode 100644 index 0000000..6937321 --- /dev/null +++ b/internal/gitinfo/added_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +package gitinfo + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestAdded dates a heading from the commit that added it as it is now: a +// heading that was added, removed and added again dates from the last +// addition, and one that is back but not committed has no date. +func TestAdded(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + top := t.TempDir() + // The checked directory is a nested module, as in a CLI under cmd/. + dir := filepath.Join(top, "cmd", "tool") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + git := func(date string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = top + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@example.com", "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + write := func(body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "RELEASE.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + commit := func(date, msg string) { + t.Helper() + git(date, "add", ".") + git(date, "commit", "-qm", msg) + } + const head = "## v1.1.0" + git("2026-01-01T00:00:00Z", "init", "-q") + write("# Releases\n\n## v1.0.0\n\n- First.\n") + commit("2026-01-01T00:00:00Z", "v1.0.0") + r := Open(dir) + if _, ok := r.Added("RELEASE.md", head); ok { + t.Fatal("never added: no date") + } + + write("# Releases\n\n## v1.1.0\n\n- Second.\n\n## v1.0.0\n\n- First.\n") + commit("2026-01-02T00:00:00Z", "add v1.1.0") + want := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + if at, ok := r.Added("RELEASE.md", head); !ok || !at.Equal(want) { + t.Fatalf("added once: %v %v, want %v", at, ok, want) + } + + // Folded away, then back without a commit. + write("# Releases\n\n## v1.0.0\n\n- First.\n") + commit("2026-01-03T00:00:00Z", "fold v1.1.0") + write("# Releases\n\n## v1.1.0\n\n- Third.\n\n## v1.0.0\n\n- First.\n") + if at, ok := r.Added("RELEASE.md", head); ok { + t.Fatalf("back but not committed: %v, want no date", at) + } + + commit("2026-01-09T00:00:00Z", "add v1.1.0 again") + want = time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC) + if at, ok := r.Added("RELEASE.md", head); !ok || !at.Equal(want) { + t.Fatalf("added again: %v %v, want %v (the last addition, not the first)", at, ok, want) + } + + // An edit that keeps the heading does not move its date. + write("# Releases\n\n## v1.1.0\n\n- Third, reworded.\n\n## v1.0.0\n\n- First.\n") + commit("2026-01-10T00:00:00Z", "reword") + if at, ok := r.Added("RELEASE.md", head); !ok || !at.Equal(want) { + t.Fatalf("after an edit: %v %v, want %v", at, ok, want) + } +} diff --git a/internal/gitinfo/gitinfo.go b/internal/gitinfo/gitinfo.go index 14c9597..02c3256 100644 --- a/internal/gitinfo/gitinfo.go +++ b/internal/gitinfo/gitinfo.go @@ -59,24 +59,33 @@ func (r *Repo) Latest() (Version, bool) { // HasTag reports whether v is tagged. func (r *Repo) HasTag(v Version) bool { return slices.Contains(r.Tags(), v) } -// Added returns the time of the oldest commit that added s to file, and -// false when no commit did (s is not committed yet). +// Added returns the time of the commit that added s to file as it is now: +// the newest commit that changed how often s occurs in file, when file holds +// s at that commit. A string that was added, removed and added again dates +// from the last addition. file is relative to the repository's directory. +// Added returns false when no commit added s, or when the newest such commit +// removed it (s is back in the work tree but not committed yet). func (r *Repo) Added(file, s string) (time.Time, bool) { if r == nil { return time.Time{}, false } - out, err := exec.Command("git", "-C", r.Dir, "log", "--format=%ct", "-S"+s, "--", file).Output() + out, err := exec.Command("git", "-C", r.Dir, "log", "-1", "--format=%H %ct", "-S"+s, "--", file).Output() if err != nil { return time.Time{}, false } f := strings.Fields(string(out)) - if len(f) == 0 { + if len(f) != 2 { return time.Time{}, false } - sec, err := strconv.ParseInt(f[len(f)-1], 10, 64) + sec, err := strconv.ParseInt(f[1], 10, 64) if err != nil { return time.Time{}, false } + // "./" makes the path relative to r.Dir, not to the top of the work tree. + blob, err := exec.Command("git", "-C", r.Dir, "show", f[0]+":./"+file).Output() + if err != nil || !strings.Contains(string(blob), s) { + return time.Time{}, false + } return time.Unix(sec, 0), true }