v0.26.1 dfd6f0b

workflow_guard_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 := g.ChangedWorkflow(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 := g.ChangedWorkflow(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 TestChangedWorkflowReadsTheQuarantineOfThePushOnly(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.ChangedWorkflow(ctx, f.Name, up, o)
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.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
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.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
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 TestChangedWorkflowResolvesSymbolicLinks(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.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
123	assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124	_, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
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.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
131	require.Error(t, err)
132	assert.NotContains(t, err.Error(), "outside the repository")
133}
134
135func TestChangedWorkflowThroughALinkedDataDirectory(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.ChangedWorkflow(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")})
157		require.NoError(t, err, dir)
158		assert.Equal(t, ".github/workflows/ci.yml", p, 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
185// withWorkflows is a commit whose .github/workflows is the tree given.
186func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
187	t.Helper()
188	gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
189	return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
190}
191
192// The trees of a push are the pusher's: a workflows directory that fans out
193// into ten million paths is answered with its first path, at once, by a git
194// that is stopped, without the diff ever being held in memory.
195func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
196	g, data := newGit(t)
197	ctx := context.Background()
198	f := readertest.NewFixture(t, data, "proj")
199	t.Cleanup(func() { _ = g.Close(ctx) })
200	blob := f.WriteObject("blob", "x\n")
201	level := treeOf(t, f, "100644 blob "+blob+"\tf")
202	for range 7 { // ten entries per level, all the same subtree: 10^7 paths
203		var entries []string
204		for i := range 10 {
205			entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
206		}
207		level = treeOf(t, f, entries...)
208	}
209	amp := withWorkflows(t, f, level)
210
211	var before, after runtime.MemStats
212	runtime.GC()
213	runtime.ReadMemStats(&before)
214	start := time.Now()
215	p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
216	took := time.Since(start)
217	runtime.ReadMemStats(&after)
218	require.NoError(t, err)
219	assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
220	assert.Less(t, took, 2*time.Second, "answered from the first path")
221	assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
222	// And no git is left behind.
223	out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
224	assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
225}
226
227// A path longer than the bound fails closed: the push is refused, not read on.
228func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
229	g, data := newGit(t)
230	ctx := context.Background()
231	f := readertest.NewFixture(t, data, "proj")
232	t.Cleanup(func() { _ = g.Close(ctx) })
233	level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
234	level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
235	_, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
236	assert.ErrorIs(t, err, domain.ErrInvalid)
237}
238
239// A tag may point to a blob, a tree or a commit; only the commits and trees can
240// hold a workflow, and an object that is not there refuses the push.
241func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
242	g, data := newGit(t)
243	ctx := context.Background()
244	f := readertest.NewFixture(t, data, "proj")
245	t.Cleanup(func() { _ = g.Close(ctx) })
246	f.Write("README", "r\n")
247	plain := f.Commit("plain")
248	f.Write(".github/workflows/ci.yml", "on: push\n")
249	wf := f.Commit("workflow")
250	f.Write(".github/workflows/ci.yml", "on: [push]\n")
251	f.Commit("the default branch moves on to other workflows") // HEAD is not wf
252	blob := f.WriteObject("blob", "hello\n")
253	annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
254	annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
255	rootTree := f.Git("rev-parse", wf+"^{tree}")
256	ref := func(old, new string) []domain.RefUpdate {
257		return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
258	}
259	changed := func(ups []domain.RefUpdate) (string, error) {
260		return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
261	}
262	for name, ups := range map[string][]domain.RefUpdate{
263		"create a lightweight tag on a blob":           ref(domain.ZeroOID, blob),
264		"create an annotated tag on a blob":            ref(domain.ZeroOID, annotatedBlob),
265		"a tag from a blob to another blob":            ref(blob, annotatedBlob),
266		"a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
267	} {
268		p, err := changed(ups)
269		require.NoError(t, err, name)
270		assert.Empty(t, p, name)
271	}
272	for name, ups := range map[string][]domain.RefUpdate{
273		"a tag from a blob to a commit with workflows":            ref(blob, oidOf(t, wf).String()),
274		"a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
275		"an annotated tag on a commit with workflows":             ref(domain.ZeroOID, annotatedWF),
276		"a tag on a tree with workflows":                          ref(domain.ZeroOID, rootTree),
277		"from a commit without to one with":                       ref(oidOf(t, plain).String(), annotatedWF),
278	} {
279		p, err := changed(ups)
280		require.NoError(t, err, name)
281		assert.Equal(t, ".github/workflows/ci.yml", p, name)
282	}
283	for name, ups := range map[string][]domain.RefUpdate{
284		"a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
285		"a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
286		"a malformed object":   ref(domain.ZeroOID, "ZZZZ"),
287	} {
288		_, err := changed(ups)
289		assert.Error(t, err, name)
290	}
291}
292
293// rawTree writes a tree object by hand: entries are "mode name hexid", in the
294// order given (git sorts them; the callers give them sorted).
295func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
296	t.Helper()
297	var b strings.Builder
298	for _, e := range entries {
299		raw, err := hex.DecodeString(e[2])
300		require.NoError(t, err)
301		b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
302		b.Write(raw)
303	}
304	return f.WriteObject("tree", b.String())
305}
306
307// What counts as .github/workflows is what the loader lists: a directory of
308// mode 040000 below a directory .github of mode 040000. The same tree id as a
309// gitlink, a symbolic link or a file is nothing, and turning it into a
310// directory activates it; the object id alone does not tell.
311func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
312	g, data := newGit(t)
313	ctx := context.Background()
314	f := readertest.NewFixture(t, data, "proj")
315	t.Cleanup(func() { _ = g.Close(ctx) })
316	f.Write("README", "r\n")
317	f.Commit("base")
318	yml := f.WriteObject("blob", "on: push\n")
319	wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
320	link := f.WriteObject("blob", ".github/other")
321	file := f.WriteObject("blob", "not a directory\n")
322	readme := f.WriteObject("blob", "r\n")
323
324	// A commit whose .github/workflows has the mode and id given.
325	withWorkflows := func(mode, id string) string {
326		gh := rawTree(t, f, [3]string{mode, "workflows", id})
327		return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
328	}
329	// A commit whose .github has the mode and id given.
330	withGithub := func(mode, id string) string {
331		return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
332	}
333	asDir := withWorkflows("040000", wfTree)
334	asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
335	asSymlink := withWorkflows("120000", link)
336	asFile := withWorkflows("100644", file)
337	none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
338	ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
339	githubDir := withGithub("040000", ghDir)
340	githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
341	githubFile := withGithub("100644", file)
342	githubLink := withGithub("120000", link)
343
344	for name, c := range map[string]struct {
345		old, new string
346		want     string
347	}{
348		"gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
349		"a directory to a gitlink removes it":          {asDir, asGitlink, ""},
350		"symlink to a directory":                       {asSymlink, asDir, ".github/workflows/ci.yml"},
351		"a directory to a symlink removes it":          {asDir, asSymlink, ""},
352		"file to a directory":                          {asFile, asDir, ".github/workflows/ci.yml"},
353		"a directory to a file removes it":             {asDir, asFile, ""},
354		"nothing to a directory":                       {none, asDir, ".github/workflows/ci.yml"},
355		"a directory to nothing":                       {asDir, none, ""},
356		"the same gitlink":                             {asGitlink, asGitlink, ""},
357		"gitlink to symlink":                           {asGitlink, asSymlink, ""},
358		".github a gitlink to a directory, same id":    {githubGitlink, githubDir, ".github/workflows/ci.yml"},
359		".github a directory to a gitlink":             {githubDir, githubGitlink, ""},
360		".github a file to a directory":                {githubFile, githubDir, ".github/workflows/ci.yml"},
361		".github a symlink to a directory":             {githubLink, githubDir, ".github/workflows/ci.yml"},
362		".github a directory to a symlink":             {githubDir, githubLink, ""},
363		"a new ref with a gitlink":                     {domain.ZeroOID, asGitlink, ""},
364		"a new ref with a symlink":                     {domain.ZeroOID, asSymlink, ""},
365		"a new ref with .github a gitlink":             {domain.ZeroOID, githubGitlink, ""},
366		"a new ref with a directory":                   {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
367	} {
368		p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
369		require.NoError(t, err, name)
370		assert.Equal(t, c.want, p, name)
371	}
372
373	// A tag that peels to the commit is judged the same way.
374	tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
375	p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
376	require.NoError(t, err)
377	assert.Equal(t, ".github/workflows/ci.yml", p)
378}
379
380// A history for the rules on the commits a push brings: main holds workflows A,
381// old (a branch) holds B, and the commits made here are in no ref yet, like
382// those of a push in quarantine.
383type guardHistory struct {
384	t      *testing.T
385	f      *readertest.Fixture
386	trees  map[string]string // workflows name -> tree id
387	m, o   string            // main (A) and old (B)
388	extraN int
389	readme string
390}
391
392func newGuardHistory(t *testing.T, g *gitcli.Git, data string) *guardHistory {
393	t.Helper()
394	f := readertest.NewFixture(t, data, "proj")
395	h := &guardHistory{t: t, f: f, trees: map[string]string{}}
396	h.readme = f.WriteObject("blob", "r\n")
397	for _, n := range []string{"A", "B", "C", "D"} {
398		h.trees[n] = rawTree(t, f, [3]string{"100644", "ci.yml", f.WriteObject("blob", "on: push # "+n+"\n")})
399	}
400	h.m = h.commit("A", "")
401	h.o = h.commit("B", "")
402	f.Git("update-ref", "refs/heads/main", h.m)
403	f.Git("update-ref", "refs/heads/old", h.o)
404	return h
405}
406
407// commit makes a commit with the workflows named ("" for none), one more file
408// each time (so that no two commits share a tree by accident), and the parents.
409func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
410	h.t.Helper()
411	h.extraN++
412	extra := h.f.WriteObject("blob", fmt.Sprintf("file %d\n", h.extraN))
413	entries := [][3]string{{"100644", "README", h.readme}, {"100644", fmt.Sprintf("x%d", h.extraN), extra}}
414	if workflows != "" {
415		gh := rawTree(h.t, h.f, [3]string{"040000", "workflows", h.trees[workflows]})
416		entries = append([][3]string{{"040000", ".github", gh}}, entries...)
417	}
418	return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
419}
420
421func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) {
422	g, data := newGit(t)
423	ctx := context.Background()
424	h := newGuardHistory(t, g, data)
425	t.Cleanup(func() { _ = g.Close(ctx) })
426	f := h.f
427	run := func(ups ...domain.RefUpdate) (string, error) {
428		return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
429	}
430	create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} }
431	move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} }
432	const ci = ".github/workflows/ci.yml"
433
434	allowed := func(name string, ups ...domain.RefUpdate) {
435		t.Helper()
436		p, err := run(ups...)
437		require.NoError(t, err, name)
438		assert.Empty(t, p, name)
439	}
440	refused := func(name string, ups ...domain.RefUpdate) {
441		t.Helper()
442		p, err := run(ups...)
443		require.NoError(t, err, name)
444		assert.Equal(t, ci, p, name)
445	}
446
447	// The staging repro: a new branch off main with a commit that touches no workflow.
448	allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m)))
449	allowed("a new branch at the head of main", create("refs/heads/same", h.m))
450	allowed("a new tag at the head of main", create("refs/tags/v1", h.m))
451	allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", "")))
452	allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m))))
453	allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m)))
454
455	// A pushed commit that changes them is refused wherever it goes.
456	refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m)))
457	refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", "")))
458	refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m)))
459	n4 := h.commit("C", "", h.m)
460	n5 := h.commit("A", "", n4) // reverts the change of the commit before
461	refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5))
462	refused("a new branch through the same", create("refs/heads/through", n5))
463	n6 := h.commit("A", "", h.commit("C", "", h.o))
464	refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6)))
465
466	// A ref cannot be moved onto existing commits with other workflows.
467	refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o))
468	refused("a new tag at that commit", create("refs/tags/old", h.o))
469	refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o))
470	allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o)))
471	allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o)))
472
473	// Merges: a change only if it differs from every parent.
474	allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o)))
475	allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o)))
476	refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o)))
477	refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o))))
478	// One that takes the old side's workflows is a change of the ref, not of a commit.
479	refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o)))
480
481	// Several updates: the first refused refuses the push.
482	refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m)))
483	allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID))
484}
485
486// With no default branch head, a new ref is compared with no workflows at all.
487func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) {
488	g, data := newGit(t)
489	ctx := context.Background()
490	h := newGuardHistory(t, g, data)
491	t.Cleanup(func() { _ = g.Close(ctx) })
492	h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist
493	run := func(to string) string {
494		p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{})
495		require.NoError(t, err)
496		return p
497	}
498	assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows")
499	plain := h.commit("", "")
500	h.f.Git("update-ref", "refs/heads/plain", plain)
501	assert.Empty(t, run(plain), "an existing commit without")
502}
503
504// The commits of a push are bounded: more than the bound fails closed.
505func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
506	g, data := newGit(t)
507	ctx := context.Background()
508	h := newGuardHistory(t, g, data)
509	t.Cleanup(func() { _ = g.Close(ctx) })
510	// A chain of plain commits on main, made with fast-import in a ref that is
511	// removed afterwards: the objects stay, in no ref, like a push in quarantine.
512	chain := func(n int) string {
513		var b strings.Builder
514		fmt.Fprintf(&b, "reset refs/tmp/chain\nfrom %s\n", h.m)
515		for i := range n {
516			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)
517		}
518		cmd := exec.Command("git", "--git-dir="+h.f.Bare, "fast-import", "--quiet")
519		cmd.Stdin = strings.NewReader(b.String())
520		out, err := cmd.CombinedOutput()
521		require.NoError(t, err, string(out))
522		tip := h.f.Git("rev-parse", "refs/tmp/chain")
523		h.f.Git("update-ref", "-d", "refs/tmp/chain")
524		return tip
525	}
526	// Plain commits carry the workflows of main (the fast-import tree has none,
527	// so the chain is "removing" them): allowed, whatever their number, up to the bound.
528	p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{})
529	require.NoError(t, err)
530	assert.Empty(t, p)
531	_, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{})
532	assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
533}
534
535// countingReader serves synthetic rev-list output and counts what was read.
536type countingReader struct {
537	lines, read int
538	line        []byte
539	pending     []byte
540	made        int
541}
542
543func (c *countingReader) Read(p []byte) (int, error) {
544	if len(c.pending) == 0 {
545		if c.made == c.lines {
546			return 0, io.EOF
547		}
548		c.made++
549		c.pending = c.line
550	}
551	n := copy(p, c.pending)
552	c.pending = c.pending[n:]
553	c.read += n
554	return n, nil
555}
556
557// A hostile push: ten thousand commits of two hundred parents each is two
558// million ids. The bound is met after the first lines, and nothing more is read
559// or kept.
560func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
561	id := func(n int) string { return fmt.Sprintf("%040x", n+1) }
562	var b strings.Builder
563	b.WriteString(id(0))
564	for i := range 198 {
565		b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
566	}
567	b.WriteString("\n")
568	r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())}
569	kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
570	require.ErrorIs(t, err, domain.ErrInvalid)
571	assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
572	assert.Zero(t, kept, "nothing is returned with the error")
573	total := gitcli.MaxPushedCommits * len(b.String())
574	assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
575	assert.Less(t, r.read*20, total)
576
577	// Within the bounds the same lines are kept: 100 commits of 198 parents.
578	r = &countingReader{lines: 100, line: []byte(b.String())}
579	kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
580	require.NoError(t, err)
581	assert.Equal(t, 100, kept)
582
583	// The other bounds and malformed output.
584	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+"\n"+id(1)+"\n"), 1, 100)
585	assert.ErrorIs(t, err, domain.ErrInvalid, "commits")
586	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+" "+id(1)+"\n"+id(2)+"\n"), 10, 2)
587	assert.ErrorIs(t, err, domain.ErrInvalid, "ids")
588	_, err = gitcli.ParseRevList(strings.NewReader("nonsense\n"), 10, 10)
589	assert.ErrorIs(t, err, domain.ErrInvalid, "an id that is none")
590	_, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
591	assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
592}

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