v0.29.0 55fcc8f

merge_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close