// SPDX-License-Identifier: AGPL-3.0-or-later package gitcli_test import ( "context" "encoding/hex" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/adapters/gitcli" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" "bnema.dev/ermite/internal/readertest" ) func TestChangedWorkflow(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") base := f.Commit("base") f.Write(".github/workflows/ci.yml", "on: push\n") withWF := f.Commit("add workflow") f.Write("docs/x.md", "x\n") docs := f.Commit("docs") f.Write(".github/workflows/ci.yml", "on: [push]\n") edited := f.Commit("edit workflow") f.Write(".github/workflows/other.yml", "on: push\n") two := f.Commit("a second workflow") f.Remove(".github/workflows/ci.yml") oneLeft := f.Commit("remove one of two") f.Remove(".github/workflows/other.yml") removed := f.Commit("remove the last workflow") f.Write(".github/workflows-notes/a.yml", "x\n") lookalike := f.Commit("a look-alike directory") oid := func(s string) string { return s[:40] } up := func(old, new string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} } changed := func(ups ...domain.RefUpdate) string { p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{}) require.NoError(t, err) return p } assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created") assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed") assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified") 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") assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed") assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts") assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit") assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows") assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing") assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") assert.Equal(t, "", changed(), "no update") _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) assert.Error(t, err, "an object git does not have is a failure, not a pass") } func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") f.Commit("base") objects := filepath.Join(f.Bare, "objects") up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}} for name, o := range map[string]ports.PushObjects{ "outside the repository": {Dir: filepath.Join(data, "elsewhere")}, "a relative path": {Dir: "objects/incoming"}, "parent of the objects": {Dir: f.Bare}, "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"}, "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, "alternates without a dir": {Alternates: objects}, } { _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds()) assert.ErrorIs(t, err, domain.ErrInvalid, name) } // An existing directory inside the repository is accepted as a location // (the object is missing: a refusal of the object, not of the path). require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.Error(t, err) assert.NotContains(t, err.Error(), "outside the repository") // One that does not exist is refused. _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) assert.ErrorIs(t, err, domain.ErrInvalid) } // A symbolic link in the way cannot take the objects of a push out of the // repository, and a data directory reached through links still works. func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") f.Commit("base") up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}} objects := filepath.Join(f.Bare, "objects") // Inside objects/, a link to elsewhere is outside once resolved. elsewhere := t.TempDir() require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") // A link to somewhere inside objects/ is fine. require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.Error(t, err) assert.NotContains(t, err.Error(), "outside the repository") } func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) { _, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") f.Write("README", "r\n") base := f.Commit("base") f.Write(".github/workflows/ci.yml", "on: push\n") wf := f.Commit("workflow") // The server reaches the same data directory through a link, while git // (the hook) reports the real path: both must be accepted as inside. parent := t.TempDir() link := filepath.Join(parent, "data") require.NoError(t, os.Symlink(data, link)) bin, err := exec.LookPath("git") require.NoError(t, err) g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")}) t.Cleanup(func() { _ = g2.Close(ctx) }) q := filepath.Join(f.Bare, "objects", "incoming-9") require.NoError(t, os.Mkdir(q, 0o700)) for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err, dir) assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir) } } // treeOf writes a tree of the given entries ("mode type sha\tname") and returns it. func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string { t.Helper() cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree") cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n") out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) return strings.TrimSpace(string(out)) } func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string { t.Helper() args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"} for _, p := range parents { args = append(args, "-p", p) } cmd := exec.Command("git", args...) cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"} out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) return strings.TrimSpace(string(out)) } func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) blob := f.WriteObject("blob", "x\n") level := treeOf(t, f, "100644 blob "+blob+"\tf") for range 7 { // ten entries per level, all the same subtree: 10^7 paths var entries []string for i := range 10 { entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i)) } level = treeOf(t, f, entries...) } amp := level var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) start := time.Now() p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) took := time.Since(start) runtime.ReadMemStats(&after) require.NoError(t, err) assert.True(t, strings.HasPrefix(p, "d"), p) assert.Less(t, took, 2*time.Second, "answered from the first path") assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") // And no git is left behind. out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput() assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped") } // A path longer than the bound fails closed: the push is refused, not read on. func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) assert.ErrorIs(t, err, domain.ErrInvalid) } // A tag may point to a blob, a tree or a commit; only the commits and trees can // hold a workflow, and an object that is not there refuses the push. func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") plain := oidOf(t, f.Commit("plain")).String() blob := f.WriteObject("blob", "hello\n") tag := func(id, kind string) string { return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a 1 +0000\n\nm\n") } tree := f.Git("rev-parse", plain+"^{tree}") for _, r := range []struct { id string kind domain.PushObjectType }{{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}} { got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.Equal(t, r.kind, got.Ends[r.id]) if r.kind == domain.PushObjectBlob { assert.NotContains(t, got.Subtrees, r.id) } } 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"}} { _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) assert.ErrorIs(t, err, domain.ErrInvalid) } got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.Empty(t, got.Ends) assert.Empty(t, got.Commits) } // rawTree writes a tree object by hand: entries are "mode name hexid", in the // order given (git sorts them; the callers give them sorted). func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { t.Helper() var b strings.Builder for _, e := range entries { raw, err := hex.DecodeString(e[2]) require.NoError(t, err) b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000 b.Write(raw) } return f.WriteObject("tree", b.String()) } // What counts as .github/workflows is what the loader lists: a directory of // mode 040000 below a directory .github of mode 040000. The same tree id as a // gitlink, a symbolic link or a file is nothing, and turning it into a // directory activates it; the object id alone does not tell. func TestPushSubtreesEntryKinds(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) blob := f.WriteObject("blob", "x\n") tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob}) for _, r := range []struct { mode, id string kind domain.EntryKind }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} { t.Run(r.mode, func(t *testing.T) { gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id}) root := rawTree(t, f, [3]string{"040000", ".github", gh}) oid := commitOf(t, f, root) got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.Equal(t, r.kind, got.Subtrees[oid].Kind) assert.Equal(t, r.id, got.Subtrees[oid].ID.String()) assert.NotContains(t, got.Subtrees, domain.HeadKey) if r.mode != "040000" { root = rawTree(t, f, [3]string{r.mode, ".github", r.id}) oid = commitOf(t, f, root) got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.NotContains(t, got.Subtrees, oid) } }) } } // A history for the rules on the commits a push brings: main holds workflows A, // old (a branch) holds B, and the commits made here are in no ref yet, like // those of a push in quarantine. type guardHistory struct { t *testing.T f *readertest.Fixture trees map[string]string // workflows name -> tree id m, o string // main (A) and old (B) extraN int readme string } func newGuardHistory(t *testing.T, g *gitcli.Git, data string) *guardHistory { t.Helper() f := readertest.NewFixture(t, data, "proj") h := &guardHistory{t: t, f: f, trees: map[string]string{}} h.readme = f.WriteObject("blob", "r\n") for _, n := range []string{"A", "B", "C", "D"} { h.trees[n] = rawTree(t, f, [3]string{"100644", "ci.yml", f.WriteObject("blob", "on: push # "+n+"\n")}) } h.m = h.commit("A", "") h.o = h.commit("B", "") f.Git("update-ref", "refs/heads/main", h.m) f.Git("update-ref", "refs/heads/old", h.o) return h } // commit makes a commit with the workflows named ("" for none), one more file // each time (so that no two commits share a tree by accident), and the parents. func (h *guardHistory) commit(workflows, _ string, parents ...string) string { h.t.Helper() h.extraN++ extra := h.f.WriteObject("blob", fmt.Sprintf("file %d\n", h.extraN)) entries := [][3]string{{"100644", "README", h.readme}, {"100644", fmt.Sprintf("x%d", h.extraN), extra}} if workflows != "" { gh := rawTree(h.t, h.f, [3]string{"040000", "workflows", h.trees[workflows]}) entries = append([][3]string{{"040000", ".github", gh}}, entries...) } return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) } func TestPushSubtreesCommitsAndParents(t *testing.T) { g, data := newGit(t) ctx := context.Background() h := newGuardHistory(t, g, data) t.Cleanup(func() { _ = g.Close(ctx) }) side := h.commit("B", "", h.o) merge := h.commit("A", "", h.m, side) 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()) require.NoError(t, err) want := h.f.Git("rev-list", "--parents", merge, "--not", "--all") var lines []string for _, c := range got.Commits { line := c.ID.String() for _, p := range c.Parents { line += " " + p.String() } lines = append(lines, line) } assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n")) assert.Len(t, got.Commits, 2) assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String()) assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String()) // Any directory path is accepted; the reader has no workflow path policy. other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind) assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates") } // The commits of a push are bounded: more than the bound fails closed. func TestPushSubtreesBoundsThePushedCommits(t *testing.T) { g, data := newGit(t) ctx := context.Background() h := newGuardHistory(t, g, data) t.Cleanup(func() { _ = g.Close(ctx) }) // A chain of plain commits on main, made with fast-import in a ref that is // removed afterwards: the objects stay, in no ref, like a push in quarantine. chain := func(n int) string { var b strings.Builder fmt.Fprintf(&b, "reset refs/tmp/chain\nfrom %s\n", h.m) for i := range n { fmt.Fprintf(&b, "commit refs/tmp/chain\ncommitter a %d +0000\ndata 1\nc\nM 100644 inline f.txt\ndata 2\n%d\n\n", 1000+i, i%10) } cmd := exec.Command("git", "--git-dir="+h.f.Bare, "fast-import", "--quiet") cmd.Stdin = strings.NewReader(b.String()) out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) tip := h.f.Git("rev-parse", "refs/tmp/chain") h.f.Git("update-ref", "-d", "refs/tmp/chain") return tip } // Plain commits carry the workflows of main (the fast-import tree has none, // so the chain is "removing" them): allowed, whatever their number, up to the bound. 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}) require.NoError(t, err) assert.Len(t, p.Commits, 20) _, 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}) assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") } // countingReader serves synthetic rev-list output and counts what was read. type countingReader struct { lines, read int line []byte pending []byte made int } func (c *countingReader) Read(p []byte) (int, error) { if len(c.pending) == 0 { if c.made == c.lines { return 0, io.EOF } c.made++ c.pending = c.line } n := copy(p, c.pending) c.pending = c.pending[n:] c.read += n return n, nil } // A hostile push: ten thousand commits of two hundred parents each is two // million ids. The bound is met after the first lines, and nothing more is read // or kept. func TestParseRevListRejectsManyParentsEarly(t *testing.T) { id := func(n int) string { return fmt.Sprintf("%040x", n+1) } var b strings.Builder b.WriteString(id(0)) for i := range 198 { b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small } b.WriteString("\n") r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())} kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) require.ErrorIs(t, err, domain.ErrInvalid) assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") assert.Zero(t, kept, "nothing is returned with the error") total := domain.MaxPushedCommits * len(b.String()) assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) assert.Less(t, r.read*20, total) // Within the bounds the same lines are kept: 100 commits of 198 parents. r = &countingReader{lines: 100, line: []byte(b.String())} kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) require.NoError(t, err) assert.Equal(t, 100, kept) // The other bounds and malformed output. _, err = gitcli.ParseRevList(strings.NewReader(id(0)+"\n"+id(1)+"\n"), 1, 100) assert.ErrorIs(t, err, domain.ErrInvalid, "commits") _, err = gitcli.ParseRevList(strings.NewReader(id(0)+" "+id(1)+"\n"+id(2)+"\n"), 10, 2) assert.ErrorIs(t, err, domain.ErrInvalid, "ids") _, err = gitcli.ParseRevList(strings.NewReader("nonsense\n"), 10, 10) assert.ErrorIs(t, err, domain.ErrInvalid, "an id that is none") _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") } func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) { t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) if err != nil { return "", err } c, found, err := domain.WorkflowChangeOf(ups, t) if err != nil || !found { return "", err } entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New) if err != nil { return "", err } if entry == "" { return domain.WorkflowDir, nil } return domain.WorkflowDir + "/" + entry, nil } func TestFirstDifferentEntry(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml")) entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree) require.NoError(t, err) assert.Equal(t, "ci.yml", entry) entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree) require.NoError(t, err) assert.Empty(t, entry) _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree) assert.ErrorIs(t, err, domain.ErrInvalid) } func TestChangedWorkflowPushHistories(t *testing.T) { g, data := newGit(t) ctx := context.Background() h := newGuardHistory(t, g, data) t.Cleanup(func() { _ = g.Close(ctx) }) run := func(old, tip, want string) { t.Helper() got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{}) require.NoError(t, err) assert.Equal(t, want, got) } run(domain.ZeroOID, h.commit("A", "", h.m), "") change := h.commit("C", "", h.m) revert := h.commit("A", "", change) run(h.m, revert, ".github/workflows/ci.yml") run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml") h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") run(domain.ZeroOID, h.m, ".github/workflows/ci.yml") ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}} trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) require.NoError(t, err) assert.NotContains(t, trees.Subtrees, domain.HeadKey) plain := h.commit("", "") h.f.Git("update-ref", "refs/heads/plain", plain) run(domain.ZeroOID, plain, "") }