1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6 "fmt"
7 "testing"
8
9 "bnema.dev/ermite/internal/domain"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14func TestWorkflowChangeOf(t *testing.T) {
15 id := func(n int) domain.ObjectID {
16 v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n))
17 require.NoError(t, err)
18 return v
19 }
20 a, b, c, d := id(1), id(2), id(3), id(4)
21 x, y, z := id(11), id(12), id(13)
22 dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} }
23 up := func(old, new domain.ObjectID) domain.RefUpdate {
24 o, n := old.String(), new.String()
25 if old.IsZero() {
26 o = domain.ZeroOID
27 }
28 if new.IsZero() {
29 n = domain.ZeroOID
30 }
31 return domain.RefUpdate{Old: o, New: n}
32 }
33 zero := domain.ObjectID{}
34 commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit {
35 return domain.PushedCommit{ID: v, Parents: parents}
36 }
37 type row struct {
38 name string
39 ups []domain.RefUpdate
40 commits []domain.PushedCommit
41 trees map[string]domain.Subtree
42 treeEnds []domain.ObjectID
43 blobs []domain.ObjectID
44 want domain.WorkflowTreeChange
45 found bool
46 missing bool
47 }
48 change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
49 return domain.WorkflowTreeChange{Old: old, New: new}
50 }
51 rows := []row{
52 {name: "no updates"},
53 {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
54 {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}},
55 {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true},
56 {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
57 {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}},
58 {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
59 {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
60 {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
61 {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
62 {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
63 {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
64 {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
65 {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
66 {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
67 {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
68 {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
69 {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}},
70 {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true},
71 {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true},
72 {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true},
73 {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true},
74 {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true},
75 {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
76 {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true},
77 {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
78 {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
79 }
80 rows = append(rows,
81 row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
82 row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}},
83 row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
84 )
85
86 for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
87 rows = append(rows,
88 row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true},
89 row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}},
90 row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}},
91 )
92 }
93 rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}})
94 for _, r := range rows {
95 t.Run(r.name, func(t *testing.T) {
96 data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees}
97 if !r.missing {
98 for _, u := range r.ups {
99 for _, oid := range []string{u.Old, u.New} {
100 data.Ends[oid] = domain.PushObjectCommit
101 }
102 }
103 }
104 for _, tree := range r.treeEnds {
105 data.Ends[tree.String()] = domain.PushObjectTree
106 }
107 for _, b := range r.blobs {
108 data.Ends[b.String()] = domain.PushObjectBlob
109 }
110 got, found, err := domain.WorkflowChangeOf(r.ups, data)
111 if r.missing {
112 require.ErrorIs(t, err, domain.ErrInvalid)
113 return
114 }
115 require.NoError(t, err)
116 assert.Equal(t, r.found, found)
117 assert.Equal(t, r.want, got)
118 })
119 }
120}