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
+159
View File
@@ -5,6 +5,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -362,6 +364,36 @@ func writeCampaignDir(t *testing.T, root, slug, frontmatterExtra, body string) {
writeFile(t, path, "---\n"+frontmatterExtra+"---\n"+body+"\n")
}
// ---- git test fixtures, for ChangedSlugs/SyncChanged ----
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func initGitRepo(t *testing.T, dir string) {
t.Helper()
runGit(t, dir, "init", "-q", "-b", "master")
}
// gitCommit stages everything and commits, returning the new commit's SHA.
func gitCommit(t *testing.T, dir, message string) string {
t.Helper()
runGit(t, dir, "add", "-A")
runGit(t, dir, "commit", "-q", "-m", message)
return runGit(t, dir, "rev-parse", "HEAD")
}
const baseFrontmatter = "subject: \"Big Announcement\"\nlists: [\"Newsletter\"]\nfrom_email: hello@example.com\n"
// ---- tests ----
@@ -733,3 +765,130 @@ func TestSyncDir_OneBadCampaignDoesNotAbortOthers(t *testing.T) {
t.Errorf("expected bad to be rejected with a slug-prefixed message, got %v", result.Rejected)
}
}
// ---- ChangedSlugs / SyncChanged ----
func TestChangedSlugs_ReturnsOnlySlugsTouchedSinceGivenCommit(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
writeCampaignDir(t, root, "b", baseFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a")
slugs, ok := ChangedSlugs(root, base)
if !ok {
t.Fatal("expected ChangedSlugs to resolve a valid commit")
}
if !slugs["a"] || slugs["b"] {
t.Errorf("expected only 'a' in changed slugs, got %v", slugs)
}
}
func TestChangedSlugs_AllZeroShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// The all-zero SHA is what a push event reports as "before" for a
// brand-new branch's first push -- there's no real commit to diff
// against, so this must signal "can't determine," not "empty diff."
_, ok := ChangedSlugs(root, strings.Repeat("0", 40))
if ok {
t.Error("expected the all-zero SHA to be treated as unresolvable")
}
}
func TestChangedSlugs_UnknownShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// A commit git has never heard of (e.g. one this shallow clone never
// fetched) must fall back to "can't determine," not error out or
// silently produce an empty diff.
_, ok := ChangedSlugs(root, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if ok {
t.Error("expected an unknown commit to be treated as unresolvable")
}
}
func TestChangedSlugs_EmptySinceMeansUnresolvable(t *testing.T) {
root := t.TempDir()
_, ok := ChangedSlugs(root, "")
if ok {
t.Error("expected an empty since to be treated as unresolvable")
}
}
func TestSyncChanged_OnlyProcessesCampaignsTouchedSinceGivenCommit(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
// b's frontmatter references a list that doesn't exist in listmonk --
// if SyncChanged ever looks at it, it comes back Rejected.
bFrontmatter := "subject: \"B\"\nlists: [\"No Such List\"]\nfrom_email: hello@example.com\n"
writeCampaignDir(t, root, "b", bFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
// A full scan at this point must reject b -- sanity check that b's
// broken list reference is real, not a mistake in the fixture.
sanity, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("sanity SyncDir: %v", err)
}
if len(sanity.Rejected) != 1 || !strings.Contains(sanity.Rejected[0], "b:") {
t.Fatalf("expected a full scan to reject b, got Rejected=%v", sanity.Rejected)
}
// Reset: the sanity run may have created campaign "a" in listmonk.
f.campaigns = nil
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a only")
result, err := SyncChanged(lm, root, nil, base)
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected only a synced, got Synced=%v", result.Synced)
}
// The real point of this test: b was never even looked at, despite its
// broken list reference, because it wasn't touched by this push.
if len(result.Rejected) != 0 {
t.Errorf("expected b to be left untouched (not rejected) since it wasn't in the diff, got Rejected=%v", result.Rejected)
}
if !strings.Contains(result.Mode, "1 of 2") {
t.Errorf("expected Mode to report 1 of 2 campaigns touched, got %q", result.Mode)
}
}
func TestSyncChanged_FallsBackToFullScanWhenSinceUnresolvable(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello a.")
gitCommit(t, root, "add a")
result, err := SyncChanged(lm, root, nil, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected a fallback full scan to still sync a, got Synced=%v", result.Synced)
}
if !strings.Contains(result.Mode, "full scan") {
t.Errorf("expected Mode to note the fallback to a full scan, got %q", result.Mode)
}
}