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