1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/readertest"
15)
16
17func oidOf(t *testing.T, s string) domain.ObjectID {
18 t.Helper()
19 id, err := domain.ParseObjectID(s)
20 require.NoError(t, err)
21 return id
22}
23
24// mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat.
25type mergeFixture struct {
26 f *readertest.Fixture
27 a, b, c, d domain.ObjectID
28 mainTip, featTip domain.ObjectID
29}
30
31func newMergeFixture(t *testing.T, data string) mergeFixture {
32 t.Helper()
33 f := readertest.NewFixture(t, data, "proj")
34 f.Write("a.txt", "one\ntwo\nthree\n")
35 a := f.Commit("add a")
36 f.Write("b.txt", "bee\n")
37 b := f.Commit("add b")
38 f.Git("update-ref", "refs/heads/main", b)
39 f.CheckoutNew("feat")
40 f.Write("a.txt", "one\n2\nthree\n")
41 c := f.Commit("change a")
42 f.Write("c.txt", "see\n")
43 d := f.Commit("add c")
44 f.Git("update-ref", "refs/heads/feat", d)
45 return mergeFixture{
46 f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d),
47 mainTip: oidOf(t, b), featTip: oidOf(t, d),
48 }
49}
50
51func TestBranchHeadAndHasCommit(t *testing.T) {
52 g, data := newGit(t)
53 ctx := context.Background()
54 m := newMergeFixture(t, data)
55 t.Cleanup(func() { _ = g.Close(ctx) })
56
57 got, err := g.BranchHead(ctx, m.f.Name, "feat")
58 require.NoError(t, err)
59 assert.Equal(t, m.d, got)
60
61 for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} {
62 _, err = g.BranchHead(ctx, m.f.Name, branch)
63 assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch)
64 }
65 _, err = g.BranchHead(ctx, name(t, "missing"), "main")
66 assert.ErrorIs(t, err, domain.ErrNotFound)
67
68 ok, err := g.HasCommit(ctx, m.f.Name, m.c)
69 require.NoError(t, err)
70 assert.True(t, ok)
71 ok, err = g.HasCommit(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)))
72 require.NoError(t, err)
73 assert.False(t, ok)
74 // A tree is not a commit.
75 tree, err := g.CommitTree(ctx, m.f.Name, m.d)
76 require.NoError(t, err)
77 ok, err = g.HasCommit(ctx, m.f.Name, tree)
78 require.NoError(t, err)
79 assert.False(t, ok)
80}
81
82func TestMergeBaseAndAncestry(t *testing.T) {
83 g, data := newGit(t)
84 ctx := context.Background()
85 m := newMergeFixture(t, data)
86 // A branch that diverged from a.
87 m.f.Git("checkout", "--quiet", "-b", "side", m.a.String())
88 m.f.Write("s.txt", "side\n")
89 side := oidOf(t, m.f.Commit("side"))
90 m.f.Git("update-ref", "refs/heads/side", side.String())
91 // An unrelated root.
92 m.f.Git("checkout", "--quiet", "--orphan", "other")
93 m.f.Git("rm", "-rf", "--quiet", ".")
94 m.f.Write("o.txt", "other\n")
95 other := oidOf(t, m.f.Commit("other root"))
96
97 base, err := g.MergeBase(ctx, m.f.Name, m.mainTip, m.featTip)
98 require.NoError(t, err)
99 assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base")
100 base, err = g.MergeBase(ctx, m.f.Name, side, m.featTip)
101 require.NoError(t, err)
102 assert.Equal(t, m.a, base)
103 _, err = g.MergeBase(ctx, m.f.Name, other, m.featTip)
104 assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history")
105
106 for _, tc := range []struct {
107 anc, desc domain.ObjectID
108 want bool
109 }{
110 {m.mainTip, m.featTip, true},
111 {m.featTip, m.mainTip, false},
112 {m.featTip, m.featTip, true},
113 {side, m.featTip, false},
114 {other, m.featTip, false},
115 } {
116 got, err := g.IsAncestor(ctx, m.f.Name, tc.anc, tc.desc)
117 require.NoError(t, err)
118 assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc)
119 }
120 _, err = g.IsAncestor(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)), m.featTip)
121 assert.Error(t, err, "an unknown commit is an error, not a plain no")
122}
123
124func TestListRange(t *testing.T) {
125 g, data := newGit(t)
126 ctx := context.Background()
127 m := newMergeFixture(t, data)
128
129 commits, more, err := g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 10)
130 require.NoError(t, err)
131 assert.False(t, more)
132 require.Len(t, commits, 2)
133 assert.Equal(t, m.d, commits[0].ID, "newest first")
134 assert.Equal(t, m.c, commits[1].ID)
135 assert.Equal(t, "add c", commits[0].Summary)
136
137 commits, more, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 1)
138 require.NoError(t, err)
139 assert.True(t, more)
140 require.Len(t, commits, 1)
141
142 commits, more, err = g.ListRange(ctx, m.f.Name, domain.ObjectID{}, m.featTip, 10)
143 require.NoError(t, err)
144 assert.False(t, more)
145 assert.Len(t, commits, 4, "a zero exclude lists the whole history")
146
147 commits, _, err = g.ListRange(ctx, m.f.Name, m.featTip, m.mainTip, 10)
148 require.NoError(t, err)
149 assert.Empty(t, commits)
150
151 _, _, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 0)
152 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
153}
154
155func TestDiffCommits(t *testing.T) {
156 g, data := newGit(t)
157 ctx := context.Background()
158 m := newMergeFixture(t, data)
159
160 stats, diff, err := g.DiffCommits(ctx, m.f.Name, m.mainTip, m.featTip, domain.DefaultDiffLimits())
161 require.NoError(t, err)
162 assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats)
163 require.Len(t, diff.Files, 2)
164 assert.Equal(t, "a.txt", diff.Files[0].NewPath)
165 assert.Equal(t, "c.txt", diff.Files[1].NewPath)
166 assert.True(t, diff.HasLine("a.txt", domain.SideNew, 2))
167 assert.True(t, diff.HasLine("a.txt", domain.SideOld, 2))
168 assert.True(t, diff.HasLine("c.txt", domain.SideNew, 1))
169 assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1))
170
171 // The diff between two commits of a branch.
172 stats, _, err = g.DiffCommits(ctx, m.f.Name, m.c, m.featTip, domain.DefaultDiffLimits())
173 require.NoError(t, err)
174 assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats)
175}
176
177func TestWriteCommitAndUpdateBranch(t *testing.T) {
178 g, data := newGit(t)
179 ctx := context.Background()
180 m := newMergeFixture(t, data)
181
182 tree, err := g.CommitTree(ctx, m.f.Name, m.featTip)
183 require.NoError(t, err)
184 assert.Equal(t, m.f.Git("rev-parse", m.featTip.String()+"^{tree}"), tree.String())
185
186 obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" +
187 "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n"
188 id, err := g.WriteCommit(ctx, m.f.Name, []byte(obj))
189 require.NoError(t, err)
190 assert.Equal(t, "commit", m.f.Git("cat-file", "-t", id.String()))
191 assert.Equal(t, "squashed", m.f.Git("log", "-1", "--format=%s", id.String()))
192
193 _, err = g.WriteCommit(ctx, m.f.Name, []byte("not a commit"))
194 assert.Error(t, err, "git checks the object")
195
196 // The old value is a precondition.
197 err = g.UpdateBranch(ctx, m.f.Name, "main", m.featTip, id)
198 assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat")
199 assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
200
201 require.NoError(t, g.UpdateBranch(ctx, m.f.Name, "main", m.mainTip, id))
202 assert.Equal(t, id.String(), m.f.Git("rev-parse", "refs/heads/main"))
203
204 err = g.UpdateBranch(ctx, m.f.Name, "refs/heads/main", id, m.mainTip)
205 assert.Error(t, err, "only short branch names")
206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
207 assert.ErrorIs(t, err, domain.ErrNotFound)
208}