-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathrepository_promisor_test.go
More file actions
357 lines (304 loc) · 12.4 KB
/
Copy pathrepository_promisor_test.go
File metadata and controls
357 lines (304 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
package git
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-git/go-git/v6/internal/test/gitenv"
"github.com/go-git/go-git/v6/plumbing"
)
// The partial-clone tests below use the git binary to build the fixture and to
// judge the result. Both matter: only real git produces the on-disk shape of a
// partial clone (promisor-marked packs plus genuinely absent objects), and only
// real git decides whether an absence reads as promised or as corruption. A
// pure go-git assertion would happily accept a repository that git refuses to
// gc, which is exactly the bug these cover.
func requireGitBinary(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("oracle disabled: -short")
}
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("oracle disabled: git not found: %v", err)
}
}
// partialCloneMinGit is the first Git release whose clone accepts --filter.
// Partial clone landed in 2.19.0, so on anything older the fixture cannot be
// built at all: `git clone --filter=blob:none` fails with "unknown option".
// The Git Compatibility workflow builds 2.11.0, which is exactly that case.
//
// A version comparison is the right test here, unlike for a behavioural bug a
// vendor might backport: the option either exists in the CLI or it does not,
// and no distribution adds partial clone to 2.11.0.
var partialCloneMinGit = [2]int{2, 19}
// requireGitPartialClone skips unless the git binary can build the fixture
// these tests need.
func requireGitPartialClone(t *testing.T) {
t.Helper()
requireGitBinary(t)
versionCmd := gitenv.Command("git", "version")
out, err := versionCmd.Output()
if err != nil {
t.Skipf("cannot determine git version: %v", err)
}
major, minor, ok := parseGitMajorMinor(string(out))
if !ok {
t.Skipf("cannot parse git version from %q", strings.TrimSpace(string(out)))
}
wantMajor, wantMinor := partialCloneMinGit[0], partialCloneMinGit[1]
if major < wantMajor || (major == wantMajor && minor < wantMinor) {
t.Skipf("git %d.%d has no clone --filter, so a partial clone cannot be built (needs %d.%d+)",
major, minor, wantMajor, wantMinor)
}
}
// parseGitMajorMinor extracts the major and minor version from `git version`
// output, which carries a variable number of trailing components and, on some
// platforms, a vendor suffix. ok is false when the output cannot be read, which
// callers treat as "unknown, skip" rather than assuming a modern Git.
func parseGitMajorMinor(out string) (major, minor int, ok bool) {
fields := strings.Fields(out)
if len(fields) < 3 || fields[0] != "git" || fields[1] != "version" {
return 0, 0, false
}
parts := strings.Split(fields[2], ".")
if len(parts) < 2 {
return 0, 0, false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
minor, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
return major, minor, true
}
// git runs a git command that is expected to succeed and returns its output.
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
// protocol.file.allow keeps file:// transport usable across git versions
// that restrict it by default.
full := append([]string{"-C", dir, "-c", "protocol.file.allow=always"}, args...)
cmd := gitenv.Command("git", full...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
return string(out)
}
// gitAllowFail runs a git command and returns its output and whether it
// succeeded, for the cases where the failure is the thing under test.
func gitAllowFail(t *testing.T, dir string, args ...string) (string, bool) {
t.Helper()
full := append([]string{"-C", dir, "-c", "protocol.file.allow=always"}, args...)
cmd := gitenv.Command("git", full...)
out, err := cmd.CombinedOutput()
return string(out), err == nil
}
// newPartialClone builds a real partial clone with the given filter and returns
// its path. The source history rewrites the same file on every commit, so each
// commit contributes a distinct blob and the clone is left genuinely missing
// objects rather than trivially complete.
//
// The clone is made with --no-checkout so no lazy backfill happens: checking out
// a worktree would fetch the objects it needs and quietly heal the very state
// under test.
func newPartialClone(t *testing.T, filter string) string {
t.Helper()
base := t.TempDir()
src := filepath.Join(base, "src.git")
seed := filepath.Join(base, "seed")
dst := filepath.Join(base, "clone")
require.NoError(t, os.MkdirAll(src, 0o755))
require.NoError(t, os.MkdirAll(seed, 0o755))
initBare := gitenv.Command("git", "init", "-q", "--bare", src)
out, err := initBare.CombinedOutput()
require.NoError(t, err, "git init --bare: %s", out)
git(t, src, "config", "uploadpack.allowFilter", "true")
initSeed := gitenv.Command("git", "init", "-q", seed)
out, err = initSeed.CombinedOutput()
require.NoError(t, err, "git init: %s", out)
git(t, seed, "config", "user.email", "test@example.com")
git(t, seed, "config", "user.name", "test")
for _, content := range []string{"one", "two", "three", "four"} {
require.NoError(t, os.WriteFile(filepath.Join(seed, "file.txt"), []byte(content+"\n"), 0o644))
git(t, seed, "add", ".")
git(t, seed, "commit", "-qm", content)
}
git(t, seed, "branch", "-M", "main")
git(t, seed, "remote", "add", "origin", src)
git(t, seed, "push", "-q", "origin", "main")
cloneCmd := gitenv.Command("git", "-c", "protocol.file.allow=always", "clone", "-q",
"--filter="+filter, "--no-checkout", "file://"+src, dst)
out, err = cloneCmd.CombinedOutput()
require.NoError(t, err, "git clone --filter=%s: %s", filter, out)
// Sanity-check the fixture really is a partial clone with absent objects,
// so a later clean fsck means the markers did their job rather than that
// there was nothing to promise.
require.NotEmpty(t, promisorMarkers(t, dst), "fixture should have promisor-marked packs")
require.NotZero(t, missingObjects(t, dst), "fixture should be missing objects")
requireFsckClean(t, dst)
return dst
}
func packDir(dir string) string {
return filepath.Join(dir, ".git", "objects", "pack")
}
func promisorMarkers(t *testing.T, dir string) []string {
t.Helper()
m, err := filepath.Glob(filepath.Join(packDir(dir), "*.promisor"))
require.NoError(t, err)
return m
}
// missingObjects counts the objects git reaches from the refs but does not have.
func missingObjects(t *testing.T, dir string) int {
t.Helper()
out := git(t, dir, "rev-list", "--objects", "--all", "--missing=print")
var n int
for line := range strings.SplitSeq(out, "\n") {
if strings.HasPrefix(line, "?") {
n++
}
}
return n
}
// requireFsckClean asserts git considers every absence promised. fsck reports
// "broken link"/"missing blob" for an object that is absent without a promisor
// pack vouching for it, and stays silent when one does.
func requireFsckClean(t *testing.T, dir string) {
t.Helper()
out, ok := gitAllowFail(t, dir, "fsck")
assert.True(t, ok, "git fsck failed: %s", out)
assert.NotContains(t, out, "broken link", "git fsck: %s", out)
assert.NotContains(t, out, "missing blob", "git fsck: %s", out)
}
// requireNoOrphanMarkers asserts every .promisor still has the pack it belongs
// to. An orphan claims a pack that is gone, so the objects it vouched for are no
// longer understood as promised.
func requireNoOrphanMarkers(t *testing.T, dir string) {
t.Helper()
for _, marker := range promisorMarkers(t, dir) {
pack := strings.TrimSuffix(marker, ".promisor") + ".pack"
_, err := os.Stat(pack)
assert.NoError(t, err, "orphaned marker %s has no pack", filepath.Base(marker))
}
}
// partialCloneFilters covers the two shapes that leave different object types
// absent: blob:none withholds blobs, which the walk reaches through tree
// entries, while tree:0 withholds the trees themselves, which it reaches by
// loading them. Each exercises a different tolerance path.
var partialCloneFilters = []string{"blob:none", "tree:0"}
// TestRepackObjectsOnPartialClone covers repacking a partial clone. It used to
// fail outright with "object not found", because the walk assumed every object
// a tree names is present, which a filtered clone breaks.
//
// The pack it produces has to be promisor-marked in turn: it carries the objects
// that reference the withheld ones, so leaving it unmarked would turn those
// absences into corruption and cost the repository its ability to gc.
func TestRepackObjectsOnPartialClone(t *testing.T) {
t.Parallel()
requireGitPartialClone(t)
for _, filter := range partialCloneFilters {
t.Run(filter, func(t *testing.T) {
t.Parallel()
dir := newPartialClone(t, filter)
before := missingObjects(t, dir)
r, err := PlainOpen(dir)
require.NoError(t, err)
defer func() { _ = r.Close() }()
require.NoError(t, r.RepackObjects(&RepackConfig{}))
assert.NotEmpty(t, promisorMarkers(t, dir),
"the repacked pack must stay promisor-marked, or the objects the remote withheld read as corruption")
requireNoOrphanMarkers(t, dir)
assert.Equal(t, before, missingObjects(t, dir), "repacking must not change which objects are absent")
// The judgement that matters: git still accepts the repository, and
// can still gc it. Before the fix gc died with "unable to read".
requireFsckClean(t, dir)
out, ok := gitAllowFail(t, dir, "gc", "--prune=now")
assert.True(t, ok, "git gc failed after repack: %s", out)
requireFsckClean(t, dir)
})
}
}
// TestPruneOnPartialClone covers pruning a partial clone, which shares the walk
// that RepackObjects used to fail in.
func TestPruneOnPartialClone(t *testing.T) {
t.Parallel()
requireGitPartialClone(t)
for _, filter := range partialCloneFilters {
t.Run(filter, func(t *testing.T) {
t.Parallel()
dir := newPartialClone(t, filter)
before := missingObjects(t, dir)
r, err := PlainOpen(dir)
require.NoError(t, err)
defer func() { _ = r.Close() }()
require.NoError(t, r.Prune(PruneOptions{Handler: r.DeleteObject}))
assert.Equal(t, before, missingObjects(t, dir), "pruning must not drop a promised object")
requireNoOrphanMarkers(t, dir)
requireFsckClean(t, dir)
})
}
}
// TestPartialCloneMarkersSurviveObjectWrites covers the ordinary case of go-git
// writing into a partial clone, which must leave the existing markers alone.
func TestPartialCloneMarkersSurviveObjectWrites(t *testing.T) {
t.Parallel()
requireGitPartialClone(t)
dir := newPartialClone(t, "blob:none")
before := promisorMarkers(t, dir)
r, err := PlainOpen(dir)
require.NoError(t, err)
defer func() { _ = r.Close() }()
obj := r.Storer.NewEncodedObject()
obj.SetType(plumbing.BlobObject)
w, err := obj.Writer()
require.NoError(t, err)
_, err = w.Write([]byte("written by go-git\n"))
require.NoError(t, err)
require.NoError(t, w.Close())
_, err = r.Storer.SetEncodedObject(obj)
require.NoError(t, err)
assert.ElementsMatch(t, before, promisorMarkers(t, dir))
requireFsckClean(t, dir)
}
// TestParseGitMajorMinor covers the shapes `git version` emits. Misreading it
// would skip the partial-clone tests on a Git that supports them, or run them
// on one that does not and fail building the fixture.
func TestParseGitMajorMinor(t *testing.T) {
t.Parallel()
tests := []struct {
name string
out string
major int
minor int
ok bool
}{
{name: "release", out: "git version 2.19.0\n", major: 2, minor: 19, ok: true},
{name: "patch release", out: "git version 2.50.1\n", major: 2, minor: 50, ok: true},
{name: "vendor suffix", out: "git version 2.50.1 (Apple Git-155)\n", major: 2, minor: 50, ok: true},
{name: "build from master", out: "git version 2.55.0.525.g2c78326f81\n", major: 2, minor: 55, ok: true},
{name: "windows suffix", out: "git version 2.51.0.windows.1\n", major: 2, minor: 51, ok: true},
{name: "legacy", out: "git version 2.11.0\n", major: 2, minor: 11, ok: true},
{name: "empty", out: ""},
{name: "truncated", out: "git version\n"},
{name: "unexpected prefix", out: "hg version 2.19.0\n"},
{name: "single component", out: "git version 2\n"},
{name: "non-numeric", out: "git version next.0\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
major, minor, ok := parseGitMajorMinor(tc.out)
if !tc.ok {
assert.False(t, ok, "parseGitMajorMinor(%q) should report failure", tc.out)
return
}
require.True(t, ok, "parseGitMajorMinor(%q) should succeed", tc.out)
assert.Equal(t, tc.major, major)
assert.Equal(t, tc.minor, minor)
})
}
}