1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit_test
4
5import (
6 "context"
7 "io"
8 "os"
9 "os/exec"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/adapters/ermitegit"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/readertest"
21)
22
23// push sends the work tree's HEAD to the bare repository of the fixture as a
24// push does: git receive-pack writes a pack (or loose objects) and updates
25// the ref, with no help from the reader.
26func push(t *testing.T, f *readertest.Fixture, remote, branch string, loose bool) {
27 t.Helper()
28 args := []string{"push", "--quiet", remote, "HEAD:refs/heads/" + branch}
29 if loose {
30 args = append([]string{"-c", "receive.unpackLimit=1000"}, args...)
31 }
32 cmd := exec.Command("git", args...)
33 cmd.Dir = f.Work
34 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + f.Work, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_DIR=" + f.Bare}
35 out, err := cmd.CombinedOutput()
36 require.NoError(t, err, string(out))
37}
38
39// TestFreshness proves that a read that starts after a push sees the push
40// with the adapter's own Refresh disabled (an interval of one hour: only the
41// first read refreshes): new commits in a pack and as loose objects, new
42// branches, a ref that moves, a repack that replaces the packs, a new
43// commit-graph layer, a deleted branch, tags.
44func TestFreshness(t *testing.T) {
45 ctx := context.Background()
46 a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour })
47
48 // The work tree is an ordinary clone-like repository; "remote" is the
49 // bare repository the adapter reads.
50 f := readertest.NewFixture(t, data, "pushed")
51 bare := f.Bare
52 // A separate repository makes the commits and pushes them.
53 work := readertest.NewFixture(t, t.TempDir(), "work")
54 work.Write("a.txt", "1\n")
55 work.Commit("first")
56 push(t, work, bare, "main", false)
57
58 head := func(t *testing.T) string {
59 t.Helper()
60 res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{})
61 require.NoError(t, err)
62 return res.CommitID.String()
63 }
64 rev := func(s string) domain.Revision {
65 r, err := domain.ParseRevision(s)
66 require.NoError(t, err)
67 return r
68 }
69 cli := func(t *testing.T) string {
70 t.Helper()
71 res, err := g.ResolveRevision(ctx, f.Name, domain.Revision{})
72 require.NoError(t, err)
73 return res.CommitID.String()
74 }
75 require.Equal(t, work.Git("rev-parse", "HEAD"), head(t), "the first read")
76
77 for i, step := range []struct {
78 name string
79 loose bool
80 after func()
81 }{
82 {"pack", false, nil},
83 {"loose objects", true, nil},
84 {"repack replaces the packs", false, func() { f.Git("repack", "-adf") }},
85 {"commit-graph layer", false, func() { f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split") }},
86 {"gc packs the refs", false, func() { f.Git("gc", "--quiet", "--prune=now") }},
87 } {
88 t.Run(step.name, func(t *testing.T) {
89 work.Write("a.txt", strings.Repeat("x", i+2)+"\n").Write("dir/new.txt", step.name+"\n")
90 c := work.Commit("push " + step.name)
91 push(t, work, bare, "main", step.loose)
92 // Not a single Refresh in between: the very next read sees it.
93 assert.Equal(t, c, head(t))
94 page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 3})
95 require.NoError(t, err)
96 require.NotEmpty(t, page.Commits)
97 assert.Equal(t, c, page.Commits[0].ID.String())
98 tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
99 require.NoError(t, err)
100 require.NotNil(t, tree.Entries[0].LastCommit)
101 assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String(), "dir/ was last touched by the push")
102 _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "dir/new.txt"))
103 require.NoError(t, err)
104 body, err := io.ReadAll(rc)
105 require.NoError(t, err)
106 require.NoError(t, rc.Close())
107 assert.Equal(t, step.name+"\n", string(body))
108 assert.Equal(t, cli(t), head(t), "the same answer as git")
109
110 if step.after != nil {
111 step.after()
112 assert.Equal(t, c, head(t), "after maintenance")
113 tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
114 require.NoError(t, err)
115 assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String())
116 }
117 })
118 }
119
120 t.Run("new and deleted refs", func(t *testing.T) {
121 work.Git("branch", "-f", "feature/new", "HEAD")
122 push(t, work, bare, "feature/new", false)
123 res, err := a.ResolveRevision(ctx, f.Name, rev("feature/new"))
124 require.NoError(t, err)
125 assert.Equal(t, "refs/heads/feature/new", res.Ref)
126
127 work.Git("tag", "-a", "-m", "release", "v9")
128 work.Git("push", "--quiet", bare, "refs/tags/v9")
129 refs, err := a.ListRefs(ctx, f.Name)
130 require.NoError(t, err)
131 var names []string
132 for _, r := range refs {
133 names = append(names, r.Name)
134 }
135 assert.Contains(t, names, "refs/tags/v9")
136 want, err := g.ListRefs(ctx, f.Name)
137 require.NoError(t, err)
138 assert.Equal(t, want, refs)
139
140 f.Git("update-ref", "-d", "refs/heads/feature/new")
141 _, err = a.ResolveRevision(ctx, f.Name, rev("feature/new"))
142 assert.ErrorIs(t, err, domain.ErrNotFound)
143 f.Git("pack-refs", "--all")
144 f.Git("update-ref", "-d", "refs/tags/v9")
145 _, err = a.ResolveRevision(ctx, f.Name, rev("v9"))
146 assert.ErrorIs(t, err, domain.ErrNotFound)
147 })
148
149 t.Run("the repository is deleted and recreated", func(t *testing.T) {
150 require.NoError(t, g.Trash(ctx, f.Name))
151 _, err := a.ListRefs(ctx, f.Name)
152 assert.ErrorIs(t, err, domain.ErrNotFound)
153 assert.Equal(t, 0, a.OpenRepos(), "the trash hook dropped the repository")
154
155 again := readertest.NewFixture(t, data, "pushed")
156 again.Write("b.txt", "new repo\n")
157 c := again.Commit("a new repository under the old name")
158 assert.Equal(t, c, head(t), "no trace of the old repository")
159 })
160}
161
162// TestReplacedDirectoryIsNotConfused: the cache is keyed by directory, so a
163// directory replaced by another repository (a restore, a rename onto the same
164// name) must not serve the old one: the inode tells.
165func TestReplacedDirectoryIsNotConfused(t *testing.T) {
166 ctx := context.Background()
167 a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour })
168 one := readertest.NewFixture(t, data, "one")
169 one.Write("a", "1\n")
170 c1 := one.Commit("one")
171 res, err := a.ResolveRevision(ctx, one.Name, domain.Revision{})
172 require.NoError(t, err)
173 require.Equal(t, c1, res.CommitID.String())
174
175 two := readertest.NewFixture(t, t.TempDir(), "two")
176 two.Write("b", "2\n")
177 c2 := two.Commit("two")
178 require.NoError(t, os.RemoveAll(one.Bare))
179 require.NoError(t, os.Rename(two.Bare, one.Bare))
180 res, err = a.ResolveRevision(ctx, one.Name, domain.Revision{})
181 require.NoError(t, err)
182 assert.Equal(t, c2, res.CommitID.String())
183}