Add reconcile state planning core

This commit is contained in:
2026-06-08 19:14:44 +00:00
parent cb9502f790
commit de6723c5de
7 changed files with 799 additions and 5 deletions

View File

@@ -61,6 +61,38 @@ func TestParseValidStateWithLinks(t *testing.T) {
}
}
func TestRemoveMissingOutputs(t *testing.T) {
state, err := Parse([]byte(validStateJSON(t)))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
state.Outputs = append(state.Outputs, OutputFile{
Path: "summary.txt",
Kind: OutputKindSource,
SourcePath: "summary.txt",
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Size: 10,
CreatedAt: state.CreatedAt,
UpdatedAt: state.UpdatedAt,
})
next, changed := RemoveMissingOutputs(state, []string{"summary.txt"})
if !changed {
t.Fatal("RemoveMissingOutputs() changed = false, want true")
}
if got, want := ManagedOutputPaths(next), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("paths = %#v, want %#v", got, want)
}
unchanged, changed := RemoveMissingOutputs(next, []string{"missing.txt"})
if changed {
t.Fatal("RemoveMissingOutputs() changed = true, want false")
}
if got, want := ManagedOutputPaths(unchanged), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("unchanged paths = %#v, want %#v", got, want)
}
}
func TestParseNormalizesPublishedAtOffset(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1)
state, err := Parse([]byte(body))

View File

@@ -60,6 +60,24 @@ func ManagedOutputPaths(s DistributorState) []string {
return paths
}
func RemoveMissingOutputs(s DistributorState, missingPaths []string) (DistributorState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]OutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func ProjectOutputs(outputs []OutputProjection, existing []OutputFile, now time.Time) []OutputFile {
now = now.UTC()
files := make([]OutputFile, 0, len(outputs))
@@ -122,6 +140,44 @@ func (s SharedRootState) AllManagedOutputPaths() []string {
return paths
}
func RemoveMissingSharedRootOwnerOutputs(s SharedRootState, scope OwnerScope, missingPaths []string) (SharedRootState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]SharedRootOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if output.Owner == scope {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func RemoveMissingSharedRootOutputs(s SharedRootState, missingPaths []string) (SharedRootState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]SharedRootOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func (s SharedRootState) OutputOwner(path string) (OwnerScope, bool) {
for _, output := range s.Outputs {
if output.Path == path {
@@ -264,3 +320,11 @@ func upsertOwner(owners []OwnerRecord, owner OwnerRecord) []OwnerRecord {
}
return append(next, owner)
}
func pathSet(paths []string) map[string]struct{} {
set := make(map[string]struct{}, len(paths))
for _, path := range paths {
set[path] = struct{}{}
}
return set
}

View File

@@ -132,6 +132,32 @@ func TestSharedRootOutputHelpers(t *testing.T) {
}
}
func TestRemoveMissingSharedRootOwnerOutputs(t *testing.T) {
state := validSharedRootState(t)
archive := CurrentOwnerScope("reports", "archive")
next, changed := RemoveMissingSharedRootOwnerOutputs(state, archive, []string{"report.md", "report.html"})
if !changed {
t.Fatal("RemoveMissingSharedRootOwnerOutputs() changed = false, want true")
}
if got, want := next.AllManagedOutputPaths(), []string{"report.html"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("paths = %#v, want %#v", got, want)
}
if _, ok := next.OutputOwner("report.html"); !ok {
t.Fatal("report.html owner missing, want unrelated owner preserved")
}
}
func TestRemoveMissingSharedRootOutputs(t *testing.T) {
state := validSharedRootState(t)
next, changed := RemoveMissingSharedRootOutputs(state, []string{"report.md", "report.html"})
if !changed {
t.Fatal("RemoveMissingSharedRootOutputs() changed = false, want true")
}
if got := next.AllManagedOutputPaths(); len(got) != 0 {
t.Fatalf("paths = %#v, want none", got)
}
}
func TestSharedRootProjectAndMergeOwnerOutputs(t *testing.T) {
state := validSharedRootState(t)
archive := CurrentOwnerScope("reports", "archive")