Scope sync to campaigns changed since a given commit
CI / test (push) Successful in 8s

Add SyncChanged (and the ChangedSlugs git-diff helper it's built on) as
an opt-in narrower version of SyncDir: pass the push's "before" SHA and
only the campaign directories that actually changed get looked at at
all, instead of every campaign getting resolved and diffed against
listmonk on every push. An empty or unresolvable since (new branch's
first push, a shallow clone missing that commit, any git failure) always
falls back to a full scan -- this can only ever cause more work than
strictly necessary, never a silently-skipped campaign. SyncDir itself is
now a thin wrapper (SyncChanged with since=""), so every existing caller
and test is unaffected.

campaigns sync PATH now accepts an optional trailing SINCE_SHA.
This commit is contained in:
2026-08-13 13:40:10 -04:00
parent 34d61c2e69
commit c375916f05
3 changed files with 245 additions and 10 deletions
+73 -6
View File
@@ -6,8 +6,10 @@ import (
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
)
@@ -21,22 +23,52 @@ type SyncResult struct {
Skipped []string // already sent/running/paused/etc. in listmonk — a historical record, left untouched, not an error
Rejected []string // validation/segmentation/list-lookup failure — nothing written
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
// Mode is purely for CI log visibility into how this run was scoped:
// "full scan", or "since <sha>: N of M campaign(s) touched" — see
// SyncChanged.
Mode string
}
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
// listmonk draft campaign. One bad campaign doesn't abort the rest — this
// mirrors eec's course.SyncDir/SyncResult shape exactly, extended with the
// Unchanged/PreviewFailed buckets this tool's auto-preview and diff-based
// sync need. defaultPreviewEmails is used for any campaign that doesn't set
// its own preview_emails in frontmatter.
// listmonk draft campaign. Equivalent to SyncChanged with an empty since —
// see SyncChanged for the git-diff-scoped version this wraps.
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
return SyncChanged(lm, root, defaultPreviewEmails, "")
}
// SyncChanged is SyncDir optionally scoped to just the campaigns whose
// files changed since a given commit (via ChangedSlugs' git diff), so a
// push that only touched one campaign doesn't need to fetch and diff every
// other campaign in the repo against listmonk to confirm nothing changed.
// An empty since, or ChangedSlugs failing to resolve it for any reason
// (e.g. a shallow clone missing that commit's history), falls back to a
// full scan exactly like SyncDir — this can never cause a campaign to be
// silently skipped, only ever process more than strictly necessary.
func SyncChanged(lm *listmonk.Client, root string, defaultPreviewEmails []string, since string) (*SyncResult, error) {
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
result := &SyncResult{}
mode := "full scan"
if since != "" {
if slugs, ok := ChangedSlugs(root, since); ok {
total := len(matches)
filtered := matches[:0]
for _, path := range matches {
if slugs[filepath.Base(filepath.Dir(path))] {
filtered = append(filtered, path)
}
}
matches = filtered
mode = fmt.Sprintf("since %s: %d of %d campaign(s) touched", since, len(matches), total)
} else {
mode = fmt.Sprintf("full scan (could not resolve since %s)", since)
}
}
result := &SyncResult{Mode: mode}
for _, path := range matches {
dir := filepath.Dir(path)
slug := filepath.Base(dir)
@@ -62,6 +94,41 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
return result, nil
}
// ChangedSlugs returns the set of campaign slugs (directory names under
// campaigns/) whose files differ between since and HEAD, by shelling out to
// git diff. The second return is false when since is empty, the all-zero
// placeholder SHA (what gitea's push event sends for a brand-new branch's
// first push, since there's no real "before" commit), or the diff itself
// fails for any reason — most commonly a shallow clone that doesn't have
// since's history available locally. All of those mean "can't determine
// what changed," never "nothing changed," so callers must treat false as
// "fall back to processing everything," not as an empty result.
func ChangedSlugs(root, since string) (map[string]bool, bool) {
if since == "" || since == strings.Repeat("0", 40) {
return nil, false
}
cmd := exec.Command("git", "diff", "--name-only", since, "HEAD", "--", "campaigns")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, false
}
slugs := map[string]bool{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Lines look like "campaigns/<slug>/campaign.md" or
// "campaigns/<slug>/assets/whatever.pdf" — the pathspec above
// already guarantees the "campaigns/" prefix.
parts := strings.SplitN(line, "/", 3)
if len(parts) >= 2 {
slugs[parts[1]] = true
}
}
return slugs, true
}
type syncOneResult struct {
Changed bool
Skipped string // non-empty (the listmonk status) when left untouched because it's no longer a draft