1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "fmt"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/readertest"
16)
17
18func oidOf(t *testing.T, s string) domain.ObjectID {
19 t.Helper()
20 id, err := domain.ParseObjectID(s)
21 require.NoError(t, err)
22 return id
23}
24
25// mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat.
26type mergeFixture struct {
27 f *readertest.Fixture
28 a, b, c, d domain.ObjectID
29 mainTip, featTip domain.ObjectID
30}
31
32func newMergeFixture(t *testing.T, data string) mergeFixture {
33 t.Helper()
34 f := readertest.NewFixture(t, data, "proj")
35 f.Write("a.txt", "one\ntwo\nthree\n")
36 a := f.Commit("add a")
37 f.Write("b.txt", "bee\n")
38 b := f.Commit("add b")
39 f.Git("update-ref", "refs/heads/main", b)
40 f.CheckoutNew("feat")
41 f.Write("a.txt", "one\n2\nthree\n")
42 c := f.Commit("change a")
43 f.Write("c.txt", "see\n")
44 d := f.Commit("add c")
45 f.Git("update-ref", "refs/heads/feat", d)
46 return mergeFixture{
47 f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d),
48 mainTip: oidOf(t, b), featTip: oidOf(t, d),
49 }
50}
51
52func TestBranchHeadAndHasCommit(t *testing.T) {
53 g, data := newGit(t)
54 ctx := context.Background()
55 m := newMergeFixture(t, data)
56 t.Cleanup(func() { _ = g.Close(ctx) })
57
58 got, err := g.BranchHead(ctx, m.f.Name, "feat")
59 require.NoError(t, err)
60 assert.Equal(t, m.d, got)
61
62 for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} {
63 _, err = g.BranchHead(ctx, m.f.Name, branch)
64 assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch)
65 }
66 _, err = g.BranchHead(ctx, name(t, "missing"), "main")
67 assert.ErrorIs(t, err, domain.ErrNotFound)
68
69 ok, err := g.HasCommit(ctx, m.f.Name, m.c)
70 require.NoError(t, err)
71 assert.True(t, ok)
72 ok, err = g.HasCommit(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)))
73 require.NoError(t, err)
74 assert.False(t, ok)
75 // A tree is not a commit.
76 tree, err := g.CommitTree(ctx, m.f.Name, m.d)
77 require.NoError(t, err)
78 ok, err = g.HasCommit(ctx, m.f.Name, tree)
79 require.NoError(t, err)
80 assert.False(t, ok)
81}
82
83func TestMergeBaseAndAncestry(t *testing.T) {
84 g, data := newGit(t)
85 ctx := context.Background()
86 m := newMergeFixture(t, data)
87 // A branch that diverged from a.
88 m.f.Git("checkout", "--quiet", "-b", "side", m.a.String())
89 m.f.Write("s.txt", "side\n")
90 side := oidOf(t, m.f.Commit("side"))
91 m.f.Git("update-ref", "refs/heads/side", side.String())
92 // An unrelated root.
93 m.f.Git("checkout", "--quiet", "--orphan", "other")
94 m.f.Git("rm", "-rf", "--quiet", ".")
95 m.f.Write("o.txt", "other\n")
96 other := oidOf(t, m.f.Commit("other root"))
97
98 base, err := g.MergeBase(ctx, m.f.Name, m.mainTip, m.featTip)
99 require.NoError(t, err)
100 assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base")
101 base, err = g.MergeBase(ctx, m.f.Name, side, m.featTip)
102 require.NoError(t, err)
103 assert.Equal(t, m.a, base)
104 _, err = g.MergeBase(ctx, m.f.Name, other, m.featTip)
105 assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history")
106
107 for _, tc := range []struct {
108 anc, desc domain.ObjectID
109 want bool
110 }{
111 {m.mainTip, m.featTip, true},
112 {m.featTip, m.mainTip, false},
113 {m.featTip, m.featTip, true},
114 {side, m.featTip, false},
115 {other, m.featTip, false},
116 } {
117 got, err := g.IsAncestor(ctx, m.f.Name, tc.anc, tc.desc)
118 require.NoError(t, err)
119 assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc)
120 }
121 _, err = g.IsAncestor(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)), m.featTip)
122 assert.Error(t, err, "an unknown commit is an error, not a plain no")
123}
124
125func TestListRange(t *testing.T) {
126 g, data := newGit(t)
127 ctx := context.Background()
128 m := newMergeFixture(t, data)
129
130 commits, more, err := g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 10)
131 require.NoError(t, err)
132 assert.False(t, more)
133 require.Len(t, commits, 2)
134 assert.Equal(t, m.d, commits[0].ID, "newest first")
135 assert.Equal(t, m.c, commits[1].ID)
136 assert.Equal(t, "add c", commits[0].Summary)
137
138 commits, more, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 1)
139 require.NoError(t, err)
140 assert.True(t, more)
141 require.Len(t, commits, 1)
142
143 commits, more, err = g.ListRange(ctx, m.f.Name, domain.ObjectID{}, m.featTip, 10)
144 require.NoError(t, err)
145 assert.False(t, more)
146 assert.Len(t, commits, 4, "a zero exclude lists the whole history")
147
148 commits, _, err = g.ListRange(ctx, m.f.Name, m.featTip, m.mainTip, 10)
149 require.NoError(t, err)
150 assert.Empty(t, commits)
151
152 _, _, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 0)
153 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
154}
155
156func TestDiffCommits(t *testing.T) {
157 g, data := newGit(t)
158 ctx := context.Background()
159 m := newMergeFixture(t, data)
160
161 stats, diff, err := g.DiffCommits(ctx, m.f.Name, m.mainTip, m.featTip, domain.DefaultDiffLimits())
162 require.NoError(t, err)
163 assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats)
164 require.Len(t, diff.Files, 2)
165 assert.Equal(t, "a.txt", diff.Files[0].NewPath)
166 assert.Equal(t, "c.txt", diff.Files[1].NewPath)
167 assert.True(t, diff.HasLine("a.txt", domain.SideNew, 2))
168 assert.True(t, diff.HasLine("a.txt", domain.SideOld, 2))
169 assert.True(t, diff.HasLine("c.txt", domain.SideNew, 1))
170 assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1))
171
172 // The diff between two commits of a branch.
173 stats, _, err = g.DiffCommits(ctx, m.f.Name, m.c, m.featTip, domain.DefaultDiffLimits())
174 require.NoError(t, err)
175 assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats)
176}
177
178func TestWriteCommitAndUpdateBranch(t *testing.T) {
179 g, data := newGit(t)
180 ctx := context.Background()
181 m := newMergeFixture(t, data)
182
183 tree, err := g.CommitTree(ctx, m.f.Name, m.featTip)
184 require.NoError(t, err)
185 assert.Equal(t, m.f.Git("rev-parse", m.featTip.String()+"^{tree}"), tree.String())
186
187 obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" +
188 "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n"
189 id, err := g.WriteCommit(ctx, m.f.Name, []byte(obj))
190 require.NoError(t, err)
191 assert.Equal(t, "commit", m.f.Git("cat-file", "-t", id.String()))
192 assert.Equal(t, "squashed", m.f.Git("log", "-1", "--format=%s", id.String()))
193
194 _, err = g.WriteCommit(ctx, m.f.Name, []byte("not a commit"))
195 assert.Error(t, err, "git checks the object")
196
197 // The old value is a precondition.
198 err = g.UpdateBranch(ctx, m.f.Name, "main", m.featTip, id)
199 assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat")
200 assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
201
202 require.NoError(t, g.UpdateBranch(ctx, m.f.Name, "main", m.mainTip, id))
203 assert.Equal(t, id.String(), m.f.Git("rev-parse", "refs/heads/main"))
204
205 err = g.UpdateBranch(ctx, m.f.Name, "refs/heads/main", id, m.mainTip)
206 assert.Error(t, err, "only short branch names")
207 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
208 assert.ErrorIs(t, err, domain.ErrNotFound)
209}
210
211func TestDeleteBranch(t *testing.T) {
212 g, data := newGit(t)
213 ctx := context.Background()
214 m := newMergeFixture(t, data)
215 m.f.Git("pack-refs", "--all") // the ref is in packed-refs too
216
217 // The old value is a precondition.
218 err := g.DeleteBranch(ctx, m.f.Name, "feat", m.mainTip)
219 assert.ErrorIs(t, err, domain.ErrConflict, "feat is not at main")
220 assert.Equal(t, m.featTip.String(), m.f.Git("rev-parse", "refs/heads/feat"))
221
222 require.NoError(t, g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip))
223 _, err = g.BranchHead(ctx, m.f.Name, "feat")
224 assert.ErrorIs(t, err, domain.ErrNotFound)
225 assert.Empty(t, m.f.Git("for-each-ref", "refs/heads/feat"), "gone from packed-refs too")
226
227 err = g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip)
228 assert.ErrorIs(t, err, domain.ErrConflict, "already gone")
229 err = g.DeleteBranch(ctx, m.f.Name, "refs/heads/main", m.mainTip)
230 assert.Error(t, err, "only short branch names")
231 assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
233 assert.ErrorIs(t, err, domain.ErrNotFound)
234}
235
236func TestMergeTree(t *testing.T) {
237 g, data := newGit(t)
238 ctx := context.Background()
239 m := newMergeFixture(t, data)
240 t.Cleanup(func() { _ = g.Close(ctx) })
241
242 // Clean: main is an ancestor of feat.
243 res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244 require.NoError(t, err)
245 assert.True(t, res.Clean)
246 assert.False(t, res.Tree.IsZero())
247 assert.Empty(t, res.ConflictPaths)
248
249 // Conflict: both sides change a.txt and c.txt differently.
250 m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251 m.f.Write("a.txt", "one\nTWO\nthree\n")
252 m.f.Write("c.txt", "other\n")
253 clash := oidOf(t, m.f.Commit("clash"))
254 res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255 require.NoError(t, err)
256 assert.False(t, res.Clean)
257 assert.True(t, res.Tree.IsZero())
258 assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259 assert.False(t, res.Truncated)
260
261 // No common history: not clean, no paths.
262 m.f.Git("checkout", "--quiet", "--orphan", "other")
263 m.f.Git("rm", "-rf", "--quiet", ".")
264 m.f.Write("o.txt", "other\n")
265 other := oidOf(t, m.f.Commit("other root"))
266 res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267 require.NoError(t, err)
268 assert.False(t, res.Clean)
269 assert.Empty(t, res.ConflictPaths)
270
271 // More conflicts than the cap.
272 m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273 for i := 0; i < domain.MaxConflictPaths+5; i++ {
274 m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275 }
276 base := oidOf(t, m.f.Commit("many"))
277 m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278 for i := 0; i < domain.MaxConflictPaths+5; i++ {
279 m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280 }
281 wb := oidOf(t, m.f.Commit("b side"))
282 m.f.Git("checkout", "--quiet", "wide-a")
283 for i := 0; i < domain.MaxConflictPaths+5; i++ {
284 m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285 }
286 wa := oidOf(t, m.f.Commit("a side"))
287 res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288 require.NoError(t, err)
289 assert.False(t, res.Clean)
290 assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths+5, "the adapter returns every path")
291 assert.False(t, res.Truncated, "bounding is the domain's")
292}
293
294func TestCountBehind(t *testing.T) {
295 g, data := newGit(t)
296 ctx := context.Background()
297 m := newMergeFixture(t, data)
298 t.Cleanup(func() { _ = g.Close(ctx) })
299
300 // main has b that a-based head lacks.
301 n, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302 require.NoError(t, err)
303 assert.Equal(t, 1, n)
304
305 n, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
306 require.NoError(t, err)
307 assert.Equal(t, 0, n)
308
309 // feat is 3 commits ahead of a: the raw count stops at the limit.
310 n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
311 require.NoError(t, err)
312 assert.Equal(t, 2, n)
313 n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 4)
314 require.NoError(t, err)
315 assert.Equal(t, 3, n)
316}