detached 3483340

push_trees_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6	"context"
7	"encoding/hex"
8	"fmt"
9	"io"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"runtime"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/adapters/gitcli"
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24	"bnema.dev/ermite/internal/readertest"
25)
26
27func TestChangedWorkflow(t *testing.T) {
28	g, data := newGit(t)
29	ctx := context.Background()
30	f := readertest.NewFixture(t, data, "proj")
31	t.Cleanup(func() { _ = g.Close(ctx) })
32	f.Write("README", "r\n")
33	base := f.Commit("base")
34	f.Write(".github/workflows/ci.yml", "on: push\n")
35	withWF := f.Commit("add workflow")
36	f.Write("docs/x.md", "x\n")
37	docs := f.Commit("docs")
38	f.Write(".github/workflows/ci.yml", "on: [push]\n")
39	edited := f.Commit("edit workflow")
40	f.Write(".github/workflows/other.yml", "on: push\n")
41	two := f.Commit("a second workflow")
42	f.Remove(".github/workflows/ci.yml")
43	oneLeft := f.Commit("remove one of two")
44	f.Remove(".github/workflows/other.yml")
45	removed := f.Commit("remove the last workflow")
46	f.Write(".github/workflows-notes/a.yml", "x\n")
47	lookalike := f.Commit("a look-alike directory")
48	oid := func(s string) string { return s[:40] }
49
50	up := func(old, new string) domain.RefUpdate {
51		return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
52	}
53	changed := func(ups ...domain.RefUpdate) string {
54		p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{})
55		require.NoError(t, err)
56		return p
57	}
58
59	assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created")
60	assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed")
61	assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified")
62	assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(two), oid(oneLeft))), "a file removed from a directory that stays is a change too")
63	assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed")
64	assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts")
65	assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit")
66	assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows")
67	assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing")
68	assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
69	assert.Equal(t, "", changed(), "no update")
70
71	_, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
72	assert.Error(t, err, "an object git does not have is a failure, not a pass")
73}
74
75func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) {
76	g, data := newGit(t)
77	ctx := context.Background()
78	f := readertest.NewFixture(t, data, "proj")
79	t.Cleanup(func() { _ = g.Close(ctx) })
80	f.Write("README", "r\n")
81	f.Commit("base")
82	objects := filepath.Join(f.Bare, "objects")
83	up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
84
85	for name, o := range map[string]ports.PushObjects{
86		"outside the repository":   {Dir: filepath.Join(data, "elsewhere")},
87		"a relative path":          {Dir: "objects/incoming"},
88		"parent of the objects":    {Dir: f.Bare},
89		"a dotdot out of objects":  {Dir: objects + "/../../other.git/objects"},
90		"an alternate outside":     {Dir: objects + "/incoming-1", Alternates: "/tmp"},
91		"alternates without a dir": {Alternates: objects},
92	} {
93		_, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds())
94		assert.ErrorIs(t, err, domain.ErrInvalid, name)
95	}
96	// An existing directory inside the repository is accepted as a location
97	// (the object is missing: a refusal of the object, not of the path).
98	require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
99	_, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
100	require.Error(t, err)
101	assert.NotContains(t, err.Error(), "outside the repository")
102	// One that does not exist is refused.
103	_, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
104	assert.ErrorIs(t, err, domain.ErrInvalid)
105}
106
107// A symbolic link in the way cannot take the objects of a push out of the
108// repository, and a data directory reached through links still works.
109func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) {
110	g, data := newGit(t)
111	ctx := context.Background()
112	f := readertest.NewFixture(t, data, "proj")
113	t.Cleanup(func() { _ = g.Close(ctx) })
114	f.Write("README", "r\n")
115	f.Commit("base")
116	up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
117	objects := filepath.Join(f.Bare, "objects")
118
119	// Inside objects/, a link to elsewhere is outside once resolved.
120	elsewhere := t.TempDir()
121	require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
122	_, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
123	assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124	_, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
125	assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
126
127	// A link to somewhere inside objects/ is fine.
128	require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
129	require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
130	_, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
131	require.Error(t, err)
132	assert.NotContains(t, err.Error(), "outside the repository")
133}
134
135func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) {
136	_, data := newGit(t)
137	ctx := context.Background()
138	f := readertest.NewFixture(t, data, "proj")
139	f.Write("README", "r\n")
140	base := f.Commit("base")
141	f.Write(".github/workflows/ci.yml", "on: push\n")
142	wf := f.Commit("workflow")
143	// The server reaches the same data directory through a link, while git
144	// (the hook) reports the real path: both must be accepted as inside.
145	parent := t.TempDir()
146	link := filepath.Join(parent, "data")
147	require.NoError(t, os.Symlink(data, link))
148	bin, err := exec.LookPath("git")
149	require.NoError(t, err)
150	g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")})
151	t.Cleanup(func() { _ = g2.Close(ctx) })
152	q := filepath.Join(f.Bare, "objects", "incoming-9")
153	require.NoError(t, os.Mkdir(q, 0o700))
154	for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
155		p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156			ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
157		require.NoError(t, err, dir)
158		assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir)
159	}
160}
161
162// treeOf writes a tree of the given entries ("mode type sha\tname") and returns it.
163func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string {
164	t.Helper()
165	cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree")
166	cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n")
167	out, err := cmd.CombinedOutput()
168	require.NoError(t, err, string(out))
169	return strings.TrimSpace(string(out))
170}
171
172func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string {
173	t.Helper()
174	args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"}
175	for _, p := range parents {
176		args = append(args, "-p", p)
177	}
178	cmd := exec.Command("git", args...)
179	cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"}
180	out, err := cmd.CombinedOutput()
181	require.NoError(t, err, string(out))
182	return strings.TrimSpace(string(out))
183}
184
185func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) {
186	g, data := newGit(t)
187	ctx := context.Background()
188	f := readertest.NewFixture(t, data, "proj")
189	t.Cleanup(func() { _ = g.Close(ctx) })
190	blob := f.WriteObject("blob", "x\n")
191	level := treeOf(t, f, "100644 blob "+blob+"\tf")
192	for range 7 { // ten entries per level, all the same subtree: 10^7 paths
193		var entries []string
194		for i := range 10 {
195			entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
196		}
197		level = treeOf(t, f, entries...)
198	}
199	amp := level
200
201	var before, after runtime.MemStats
202	runtime.GC()
203	runtime.ReadMemStats(&before)
204	start := time.Now()
205	p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
206	took := time.Since(start)
207	runtime.ReadMemStats(&after)
208	require.NoError(t, err)
209	assert.True(t, strings.HasPrefix(p, "d"), p)
210	assert.Less(t, took, 2*time.Second, "answered from the first path")
211	assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
212	// And no git is left behind.
213	out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
214	assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
215}
216
217// A path longer than the bound fails closed: the push is refused, not read on.
218func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) {
219	g, data := newGit(t)
220	ctx := context.Background()
221	f := readertest.NewFixture(t, data, "proj")
222	t.Cleanup(func() { _ = g.Close(ctx) })
223	level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
224	level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
225	_, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
226	assert.ErrorIs(t, err, domain.ErrInvalid)
227}
228
229// A tag may point to a blob, a tree or a commit; only the commits and trees can
230// hold a workflow, and an object that is not there refuses the push.
231func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) {
232	g, data := newGit(t)
233	ctx := context.Background()
234	f := readertest.NewFixture(t, data, "proj")
235	t.Cleanup(func() { _ = g.Close(ctx) })
236	f.Write("README", "r\n")
237	plain := oidOf(t, f.Commit("plain")).String()
238	blob := f.WriteObject("blob", "hello\n")
239	tag := func(id, kind string) string {
240		return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
241	}
242	tree := f.Git("rev-parse", plain+"^{tree}")
243	for _, r := range []struct {
244		id   string
245		kind domain.PushObjectType
246	}{{plain, domain.PushObjectCommit}, {tag(plain, "commit"), domain.PushObjectCommit}, {blob, domain.PushObjectBlob}, {tag(blob, "blob"), domain.PushObjectBlob}, {tree, domain.PushObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.PushObjectCommit}} {
247		got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
248		require.NoError(t, err)
249		assert.Equal(t, r.kind, got.Ends[r.id])
250		if r.kind == domain.PushObjectBlob {
251			assert.NotContains(t, got.Subtrees, r.id)
252		}
253	}
254	for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} {
255		_, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
256		assert.ErrorIs(t, err, domain.ErrInvalid)
257	}
258	got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
259	require.NoError(t, err)
260	assert.Empty(t, got.Ends)
261	assert.Empty(t, got.Commits)
262}
263
264// rawTree writes a tree object by hand: entries are "mode name hexid", in the
265// order given (git sorts them; the callers give them sorted).
266func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
267	t.Helper()
268	var b strings.Builder
269	for _, e := range entries {
270		raw, err := hex.DecodeString(e[2])
271		require.NoError(t, err)
272		b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
273		b.Write(raw)
274	}
275	return f.WriteObject("tree", b.String())
276}
277
278// What counts as .github/workflows is what the loader lists: a directory of
279// mode 040000 below a directory .github of mode 040000. The same tree id as a
280// gitlink, a symbolic link or a file is nothing, and turning it into a
281// directory activates it; the object id alone does not tell.
282func TestPushSubtreesEntryKinds(t *testing.T) {
283	g, data := newGit(t)
284	ctx := context.Background()
285	f := readertest.NewFixture(t, data, "proj")
286	t.Cleanup(func() { _ = g.Close(ctx) })
287	blob := f.WriteObject("blob", "x\n")
288	tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob})
289	for _, r := range []struct {
290		mode, id string
291		kind     domain.EntryKind
292	}{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} {
293		t.Run(r.mode, func(t *testing.T) {
294			gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id})
295			root := rawTree(t, f, [3]string{"040000", ".github", gh})
296			oid := commitOf(t, f, root)
297			got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
298			require.NoError(t, err)
299			assert.Equal(t, r.kind, got.Subtrees[oid].Kind)
300			assert.Equal(t, r.id, got.Subtrees[oid].ID.String())
301			assert.NotContains(t, got.Subtrees, domain.HeadKey)
302			if r.mode != "040000" {
303				root = rawTree(t, f, [3]string{r.mode, ".github", r.id})
304				oid = commitOf(t, f, root)
305				got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
306				require.NoError(t, err)
307				assert.NotContains(t, got.Subtrees, oid)
308			}
309		})
310	}
311}
312
313// A history for the rules on the commits a push brings: main holds workflows A,
314// old (a branch) holds B, and the commits made here are in no ref yet, like
315// those of a push in quarantine.
316type guardHistory struct {
317	t      *testing.T
318	f      *readertest.Fixture
319	trees  map[string]string // workflows name -> tree id
320	m, o   string            // main (A) and old (B)
321	extraN int
322	readme string
323}
324
325func newGuardHistory(t *testing.T, g *gitcli.Git, data string) *guardHistory {
326	t.Helper()
327	f := readertest.NewFixture(t, data, "proj")
328	h := &guardHistory{t: t, f: f, trees: map[string]string{}}
329	h.readme = f.WriteObject("blob", "r\n")
330	for _, n := range []string{"A", "B", "C", "D"} {
331		h.trees[n] = rawTree(t, f, [3]string{"100644", "ci.yml", f.WriteObject("blob", "on: push # "+n+"\n")})
332	}
333	h.m = h.commit("A", "")
334	h.o = h.commit("B", "")
335	f.Git("update-ref", "refs/heads/main", h.m)
336	f.Git("update-ref", "refs/heads/old", h.o)
337	return h
338}
339
340// commit makes a commit with the workflows named ("" for none), one more file
341// each time (so that no two commits share a tree by accident), and the parents.
342func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
343	h.t.Helper()
344	h.extraN++
345	extra := h.f.WriteObject("blob", fmt.Sprintf("file %d\n", h.extraN))
346	entries := [][3]string{{"100644", "README", h.readme}, {"100644", fmt.Sprintf("x%d", h.extraN), extra}}
347	if workflows != "" {
348		gh := rawTree(h.t, h.f, [3]string{"040000", "workflows", h.trees[workflows]})
349		entries = append([][3]string{{"040000", ".github", gh}}, entries...)
350	}
351	return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
352}
353
354func TestPushSubtreesCommitsAndParents(t *testing.T) {
355	g, data := newGit(t)
356	ctx := context.Background()
357	h := newGuardHistory(t, g, data)
358	t.Cleanup(func() { _ = g.Close(ctx) })
359	side := h.commit("B", "", h.o)
360	merge := h.commit("A", "", h.m, side)
361	got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
362	require.NoError(t, err)
363	want := h.f.Git("rev-list", "--parents", merge, "--not", "--all")
364	var lines []string
365	for _, c := range got.Commits {
366		line := c.ID.String()
367		for _, p := range c.Parents {
368			line += " " + p.String()
369		}
370		lines = append(lines, line)
371	}
372	assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n"))
373	assert.Len(t, got.Commits, 2)
374	assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String())
375	assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String())
376	// Any directory path is accepted; the reader has no workflow path policy.
377	other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds())
378	require.NoError(t, err)
379	assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind)
380	assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates")
381}
382
383// The commits of a push are bounded: more than the bound fails closed.
384func TestPushSubtreesBoundsThePushedCommits(t *testing.T) {
385	g, data := newGit(t)
386	ctx := context.Background()
387	h := newGuardHistory(t, g, data)
388	t.Cleanup(func() { _ = g.Close(ctx) })
389	// A chain of plain commits on main, made with fast-import in a ref that is
390	// removed afterwards: the objects stay, in no ref, like a push in quarantine.
391	chain := func(n int) string {
392		var b strings.Builder
393		fmt.Fprintf(&b, "reset refs/tmp/chain\nfrom %s\n", h.m)
394		for i := range n {
395			fmt.Fprintf(&b, "commit refs/tmp/chain\ncommitter a <a@b> %d +0000\ndata 1\nc\nM 100644 inline f.txt\ndata 2\n%d\n\n", 1000+i, i%10)
396		}
397		cmd := exec.Command("git", "--git-dir="+h.f.Bare, "fast-import", "--quiet")
398		cmd.Stdin = strings.NewReader(b.String())
399		out, err := cmd.CombinedOutput()
400		require.NoError(t, err, string(out))
401		tip := h.f.Git("rev-parse", "refs/tmp/chain")
402		h.f.Git("update-ref", "-d", "refs/tmp/chain")
403		return tip
404	}
405	// Plain commits carry the workflows of main (the fast-import tree has none,
406	// so the chain is "removing" them): allowed, whatever their number, up to the bound.
407	p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
408	require.NoError(t, err)
409	assert.Len(t, p.Commits, 20)
410	_, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
411	assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
412}
413
414// countingReader serves synthetic rev-list output and counts what was read.
415type countingReader struct {
416	lines, read int
417	line        []byte
418	pending     []byte
419	made        int
420}
421
422func (c *countingReader) Read(p []byte) (int, error) {
423	if len(c.pending) == 0 {
424		if c.made == c.lines {
425			return 0, io.EOF
426		}
427		c.made++
428		c.pending = c.line
429	}
430	n := copy(p, c.pending)
431	c.pending = c.pending[n:]
432	c.read += n
433	return n, nil
434}
435
436// A hostile push: ten thousand commits of two hundred parents each is two
437// million ids. The bound is met after the first lines, and nothing more is read
438// or kept.
439func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
440	id := func(n int) string { return fmt.Sprintf("%040x", n+1) }
441	var b strings.Builder
442	b.WriteString(id(0))
443	for i := range 198 {
444		b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
445	}
446	b.WriteString("\n")
447	r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())}
448	kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
449	require.ErrorIs(t, err, domain.ErrInvalid)
450	assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
451	assert.Zero(t, kept, "nothing is returned with the error")
452	total := domain.MaxPushedCommits * len(b.String())
453	assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
454	assert.Less(t, r.read*20, total)
455
456	// Within the bounds the same lines are kept: 100 commits of 198 parents.
457	r = &countingReader{lines: 100, line: []byte(b.String())}
458	kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
459	require.NoError(t, err)
460	assert.Equal(t, 100, kept)
461
462	// The other bounds and malformed output.
463	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+"\n"+id(1)+"\n"), 1, 100)
464	assert.ErrorIs(t, err, domain.ErrInvalid, "commits")
465	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+" "+id(1)+"\n"+id(2)+"\n"), 10, 2)
466	assert.ErrorIs(t, err, domain.ErrInvalid, "ids")
467	_, err = gitcli.ParseRevList(strings.NewReader("nonsense\n"), 10, 10)
468	assert.ErrorIs(t, err, domain.ErrInvalid, "an id that is none")
469	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
470	assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
471}
472
473func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) {
474	t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
475	if err != nil {
476		return "", err
477	}
478	c, found, err := domain.WorkflowChangeOf(ups, t)
479	if err != nil || !found {
480		return "", err
481	}
482	entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New)
483	if err != nil {
484		return "", err
485	}
486	if entry == "" {
487		return domain.WorkflowDir, nil
488	}
489	return domain.WorkflowDir + "/" + entry, nil
490}
491
492func TestFirstDifferentEntry(t *testing.T) {
493	g, data := newGit(t)
494	ctx := context.Background()
495	f := readertest.NewFixture(t, data, "proj")
496	t.Cleanup(func() { _ = g.Close(ctx) })
497	tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml"))
498	entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree)
499	require.NoError(t, err)
500	assert.Equal(t, "ci.yml", entry)
501	entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree)
502	require.NoError(t, err)
503	assert.Empty(t, entry)
504	_, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree)
505	assert.ErrorIs(t, err, domain.ErrInvalid)
506}
507
508func TestChangedWorkflowPushHistories(t *testing.T) {
509	g, data := newGit(t)
510	ctx := context.Background()
511	h := newGuardHistory(t, g, data)
512	t.Cleanup(func() { _ = g.Close(ctx) })
513	run := func(old, tip, want string) {
514		t.Helper()
515		got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{})
516		require.NoError(t, err)
517		assert.Equal(t, want, got)
518	}
519	run(domain.ZeroOID, h.commit("A", "", h.m), "")
520	change := h.commit("C", "", h.m)
521	revert := h.commit("A", "", change)
522	run(h.m, revert, ".github/workflows/ci.yml")
523	run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml")
524	h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing")
525	run(domain.ZeroOID, h.m, ".github/workflows/ci.yml")
526	ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}}
527	trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
528	require.NoError(t, err)
529	assert.NotContains(t, trees.Subtrees, domain.HeadKey)
530	plain := h.commit("", "")
531	h.f.Git("update-ref", "refs/heads/plain", plain)
532	run(domain.ZeroOID, plain, "")
533}

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 4

Tags 45

↑↓ moveenter openesc close