Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Measure every read route, not just the clone

1dfa8cbdeda03c48ba58c2a48ea8b7157f8f9468 · Keenan Nemetz · 9d ago · parent 0448a978

Measure every read route, not just the clone

#100 asks for numbers per route. The harness now seeds three repositories —
a small one for a quick clone, a large one for a slow clone, and the demo
repository with files at both ends of the configured max_blob_bytes, a
change with a 4000-line diff, a release artifact and a hosted site — and
runs one scenario per route: clone, clone-large, blob-small, blob-limit,
raw-small, raw-limit, raw-range, overview, tree, commits, compare,
change, issue, search-code, search-find, artifact, artifact-range, site.

The clone scenario gives each worker its own forwarded address, so the
global git_http_max_concurrent is what refuses rather than the per-client
limit; the fixture trusts the header on loopback.

`VOLE_LOAD_SCENARIO=all` runs the set in order and prints a block per
route, which is what produced the numbers recorded on #100. The doc
describes the seeded shape and the limit behaviour.

server/load_test.go +355−88

@@ -9,21 +9,24 @@ package server_test
9 9 // loopback port with a temporary data directory, so the numbers describe the
10 10 // same code an operator runs, not a mock.
11 11 //
12 // One scenario is enough to prove the harness end to end: an anonymous clone
13 // over HTTP. The other routes in #100 add their own step functions; the seeding,
14 // sampling, reporting and configuration here are what they share.
12 // Run every route:
15 13 //
16 // Run it:
14 // VOLE_LOAD_SCENARIO=all VOLE_LOAD_CONCURRENCY=4 VOLE_LOAD_DURATION=5s \
15 // go test -tags load,sqlite_fts5 -run '^TestLoad$' -v ./server
17 16 //
18 // VOLE_LOAD_SCENARIO=clone VOLE_LOAD_CONCURRENCY=8 VOLE_LOAD_DURATION=30s \
19 // go test -tags load -run '^TestLoad$' -v ./server
17 // Or one route, e.g. clones past the per-client limit:
18 //
19 // VOLE_LOAD_SCENARIO=clone VOLE_LOAD_CONCURRENCY=24 VOLE_LOAD_DURATION=10s \
20 // go test -tags load,sqlite_fts5 -run '^TestLoad$' -v ./server
20 21 //
21 22 // The knobs are environment variables rather than flags so the documented
22 23 // command has no `-args` in it. See docs/load-testing.md.
23 24
24 25 import (
26 "bytes"
25 27 "fmt"
26 28 "io"
29 "math/rand"
27 30 "net/http"
28 31 "os"
29 32 "os/exec"
@@ -38,9 +41,30 @@ import (
38 41
39 42 "github.com/rodent-software/vole/core/git"
40 43 "github.com/rodent-software/vole/core/lfs"
44 "github.com/rodent-software/vole/forge/config"
41 45 "github.com/rodent-software/vole/metadata"
42 46 )
43 47
48 const (
49 loadPagesDomain = "pages.test"
50 // loadBlobLimit is the configured max_blob_bytes, so a file at the limit is
51 // something a push could actually deliver.
52 loadBlobLimit = 8 << 20
53 // loadLargeCommits and loadLargeBlob make the second repository large enough
54 // that a clone of it is a different measurement from a clone of the fixture.
55 loadLargeCommits = 3000
56 loadLargeBlob = 16 << 20
57 )
58
59 // loadSeed is what the scenarios need to name a route.
60 type loadSeed struct {
61 repo string
62 smallRepo string
63 largeRepo string
64 issueID string
65 changeID string
66 }
67
44 68 func TestLoad(t *testing.T) {
45 69 scenario := envString("VOLE_LOAD_SCENARIO", "clone")
46 70 concurrency := envInt("VOLE_LOAD_CONCURRENCY", 4)
@@ -52,32 +76,219 @@ func TestLoad(t *testing.T) {
52 76 t.Fatalf("VOLE_LOAD_DURATION=%s, want a positive duration", duration)
53 77 }
54 78
55 f := newFixture(t)
56 seedLoadRepo(t, f)
79 f := newFixtureWith(t, func(c *config.Config) {
80 c.PagesDomain = loadPagesDomain
81 c.PagesRef = "pages"
82 c.MaxBlobBytes = loadBlobLimit
83 // The clone scenario gives every worker its own forwarded address, so the
84 // global git_http_max_concurrent is the limit that refuses, not the
85 // per-client one. The fixture is loopback, so trusting the header is safe.
86 c.GitHTTPClientIPHeader = "X-Forwarded-For"
87 })
88 seed := seedLoadRepo(t, f)
89
57 90 // One read per repository builds its index before the run, so the first
58 91 // reader does not pay for the whole metadata branch inside the measurement.
59 92 f.get("/r/" + f.repo)
93 f.get("/r/" + seed.largeRepo)
94 scenarios := loadScenarios(t, f, seed)
95
96 if scenario == "all" {
97 for _, sc := range scenarios {
98 runScenario(t, f, sc, duration, concurrency)
99 t.Logf("")
100 }
101 return
102 }
103 for _, sc := range scenarios {
104 if sc.name == scenario {
105 runScenario(t, f, sc, duration, concurrency)
106 return
107 }
108 }
109 var names []string
110 for _, sc := range scenarios {
111 names = append(names, sc.name)
112 }
113 t.Fatalf("unknown VOLE_LOAD_SCENARIO %q (have: all, %s)", scenario, strings.Join(names, ", "))
114 }
115
116 type scenario struct {
117 name string
118 step loadStep
119 }
120
121 // loadStep is one request. The worker number exists so a step that needs a
122 // workspace (a clone target) gets its own instead of racing its peers.
123 type loadStep func(client *http.Client, worker int) (status string, err error)
124
125 func runScenario(t *testing.T, f *fixture, sc scenario, duration time.Duration, concurrency int) {
126 t.Helper()
127 client := &http.Client{Timeout: 120 * time.Second}
128 results := newLoadResults()
129 peaks := startSampler()
130 deadline := time.Now().Add(duration)
131
132 var wg sync.WaitGroup
133 for i := 0; i < concurrency; i++ {
134 wg.Add(1)
135 go func(worker int) {
136 defer wg.Done()
137 for time.Now().Before(deadline) {
138 start := time.Now()
139 status, err := sc.step(client, worker)
140 results.record(time.Since(start), status, err)
141 }
142 }(i)
143 }
144 wg.Wait()
145 reportLoad(t, sc.name, duration, concurrency, results, peaks.stop())
146 }
147
148 // loadScenarios names every route #100 asks about. Each is one step a worker
149 // repeats until the deadline.
150 func loadScenarios(t *testing.T, f *fixture, seed loadSeed) []scenario {
151 t.Helper()
152 cloneDir := t.TempDir()
153 repo := f.http.URL + "/r/" + seed.repo
154 siteURL := f.http.URL + "/"
155 cloneURL := f.httpURL(seed.smallRepo)
156 largeCloneURL := f.httpURL(seed.largeRepo)
157
158 return []scenario{
159 {name: "clone", step: cloneStep(cloneURL, cloneDir)},
160 {name: "clone-large", step: cloneStep(largeCloneURL, cloneDir)},
161 {name: "blob-small", step: getStep(repo + "/blob/main/small.txt")},
162 {name: "blob-limit", step: getStep(repo + "/blob/main/limit.bin")},
163 {name: "raw-small", step: getStep(repo + "/raw/main/small.txt")},
164 {name: "raw-limit", step: getStep(repo + "/raw/main/limit.bin")},
165 {name: "raw-range", step: getStepWith(repo+"/raw/main/limit.bin", map[string]string{"Range": "bytes=0-65535"})},
166 {name: "overview", step: getStep(repo)},
167 {name: "tree", step: getStep(repo + "/tree/main")},
168 {name: "commits", step: getStep(repo + "/commits/main")},
169 {name: "compare", step: getStep(repo + "/compare/main...feature")},
170 {name: "change", step: getStep(repo + "/requests/" + seed.changeID)},
171 {name: "issue", step: getStep(repo + "/issues/" + seed.issueID)},
172 {name: "search-code", step: getStep(repo + "/search?q=revision")},
173 {name: "search-find", step: getStep(f.http.URL + "/search?q=revision")},
174 {name: "artifact", step: getStep(repo + "/releases/v1/artifacts/load-artifact.txt")},
175 {name: "artifact-range", step: getStepWith(repo+"/releases/v1/artifacts/load-artifact.txt", map[string]string{"Range": "bytes=0-1023"})},
176 {name: "site", step: siteStep(siteURL, seed.repo+"."+loadPagesDomain)},
177 }
178 }
60 179
61 switch scenario {
62 case "clone":
63 runCloneScenario(t, f, duration, concurrency)
64 default:
65 t.Fatalf("unknown VOLE_LOAD_SCENARIO %q (have: clone)", scenario)
180 // getStep is one anonymous GET, with the body drained so the server sees the
181 // whole response.
182 func getStep(url string) loadStep {
183 return getStepWith(url, nil)
184 }
185
186 func getStepWith(url string, headers map[string]string) loadStep {
187 return func(client *http.Client, worker int) (string, error) {
188 req, err := http.NewRequest(http.MethodGet, url, nil)
189 if err != nil {
190 return "error", err
191 }
192 for k, v := range headers {
193 req.Header.Set(k, v)
194 }
195 resp, err := client.Do(req)
196 if err != nil {
197 return "error", err
198 }
199 _, _ = io.Copy(io.Discard, resp.Body)
200 resp.Body.Close()
201 return strconv.Itoa(resp.StatusCode), nil
202 }
203 }
204
205 // siteStep requests a hosted site page while still talking to the test listener,
206 // which is what a real request with a Host header does.
207 func siteStep(url, host string) loadStep {
208 return func(client *http.Client, worker int) (string, error) {
209 req, err := http.NewRequest(http.MethodGet, url, nil)
210 if err != nil {
211 return "error", err
212 }
213 req.Host = host
214 resp, err := client.Do(req)
215 if err != nil {
216 return "error", err
217 }
218 _, _ = io.Copy(io.Discard, resp.Body)
219 resp.Body.Close()
220 return strconv.Itoa(resp.StatusCode), nil
221 }
222 }
223
224 // cloneStep probes `info/refs` and then clones. The probe is what a server can
225 // refuse (503 past the concurrency limit), so it is recorded as the step's
226 // status; the git process's own failure is recorded separately.
227 func cloneStep(repoURL, root string) loadStep {
228 infoRefs := repoURL + "/info/refs?service=git-upload-pack"
229 return func(client *http.Client, worker int) (string, error) {
230 // A distinct forwarded address per worker keeps the per-client limit from
231 // binding before the global one.
232 forwarded := fmt.Sprintf("10.0.%d.%d", worker/250, worker%250+1)
233 status, err := probe(client, infoRefs, forwarded)
234 if err != nil {
235 return status, err
236 }
237 if status != "200" {
238 return status, nil
239 }
240 return status, cloneShallow(repoURL, filepath.Join(root, strconv.Itoa(worker)), forwarded)
241 }
242 }
243
244 func probe(client *http.Client, url, forwarded string) (string, error) {
245 req, err := http.NewRequest(http.MethodGet, url, nil)
246 if err != nil {
247 return "error", err
248 }
249 req.Header.Set("X-Forwarded-For", forwarded)
250 resp, err := client.Do(req)
251 if err != nil {
252 return "error", err
253 }
254 _, _ = io.Copy(io.Discard, resp.Body)
255 resp.Body.Close()
256 return strconv.Itoa(resp.StatusCode), nil
257 }
258
259 func cloneShallow(url, dir, forwarded string) error {
260 if err := os.RemoveAll(dir); err != nil {
261 return err
262 }
263 cmd := exec.Command("git", "-c", "http.extraHeader=X-Forwarded-For: "+forwarded,
264 "clone", "--quiet", "--depth", "1", url, dir)
265 cmd.Env = append(os.Environ(),
266 "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
267 out, err := cmd.CombinedOutput()
268 if err != nil {
269 return fmt.Errorf("clone: %v: %s", err, firstLine(string(out)))
66 270 }
271 return nil
67 272 }
68 273
69 // seedLoadRepo gives the instance a repository with the shape a real one has:
70 // history, files at a few sizes, an issue and a change request with comments and
71 // an approval, and a release artifact.
72 func seedLoadRepo(t *testing.T, f *fixture) {
274 // seedLoadRepo gives the instance the shape #100 measures: a small repository
275 // with history and files at both ends of max_blob_bytes, a data-only change with
276 // a large diff, a release artifact, a hosted site, and a second repository large
277 // enough that cloning it costs something.
278 func seedLoadRepo(t *testing.T, f *fixture) loadSeed {
73 279 t.Helper()
74 280 f.publish(f.repo)
281 seed := loadSeed{repo: f.repo, smallRepo: "small", largeRepo: "large"}
75 282
283 small := strings.Repeat("small line\n", 100)
284 medium := strings.Repeat("export const value = 42;\n", 5000)
285 big := make([]byte, loadBlobLimit)
286 rand.New(rand.NewSource(42)).Read(big)
76 287 files := map[string]string{
77 288 "README.md": "# load test\n\nA repository with enough shape to measure.\n",
78 "small.txt": strings.Repeat("small line\n", 100),
79 "medium.js": strings.Repeat("export const value = 42;\n", 5000),
80 "large.bin": strings.Repeat("x", 1<<20),
289 "small.txt": small,
290 "medium.js": medium,
291 "limit.bin": string(big),
81 292 }
82 293 work := workRepo(t, files)
83 294 for i := 0; i < 300; i++ {
@@ -92,12 +303,37 @@ func seedLoadRepo(t *testing.T, f *fixture) {
92 303 t.Fatalf("push feature: %s", errs)
93 304 }
94 305
306 // A branch whose diff is large enough to be a different page from the small
307 // one: 4000 changed lines, under the page's diff cap.
308 runGit(t, work, "checkout", "-q", "-b", "big-change")
309 appendLine(t, filepath.Join(work, "diff.txt"), strings.Repeat("a changed line for the diff\n", 4000))
310 runGit(t, work, "add", "-A")
311 runGit(t, work, "commit", "-qm", "large diff")
312 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "big-change:refs/heads/big-change"); err != nil {
313 t.Fatalf("push big-change: %s", errs)
314 }
315 runGit(t, work, "checkout", "-q", "main")
316
317 // A hosted site on the pages branch.
318 pages := workRepo(t, map[string]string{
319 "index.html": "<!doctype html><h1>load test</h1><link rel=stylesheet href=style.css>\n",
320 "style.css": strings.Repeat("body { margin: 0 }\n", 100),
321 "docs/index.html": "<p>docs</p>\n",
322 })
323 if _, errs, err := f.git(pages, "push", f.sshURL(f.repo), "main:refs/heads/pages"); err != nil {
324 t.Fatalf("push pages: %s", errs)
325 }
326
95 327 m, err := f.srv.Repos.Open(f.repo)
96 328 if err != nil {
97 329 t.Fatal(err)
98 330 }
99 331 g := m.Git()
100 332 now := time.Now().UTC()
333 head, err := g.RevParse("refs/heads/big-change")
334 if err != nil || head == "" {
335 t.Fatalf("resolve big-change: %q, %v", head, err)
336 }
101 337
102 338 iss, err := metadata.NewIssue("Load test issue",
103 339 strings.Repeat("A body with enough text to index. ", 40), "keenan",
@@ -108,6 +344,7 @@ func seedLoadRepo(t *testing.T, f *fixture) {
108 344 if err := metadata.CreateIssue(g, iss, "keenan"); err != nil {
109 345 t.Fatal(err)
110 346 }
347 seed.issueID = iss.ID
111 348 for i := 0; i < 5; i++ {
112 349 comment, err := metadata.NewIssueComment(fmt.Sprintf("Comment %d on the issue.\n", i), "keenan", now.Add(time.Duration(i)*time.Minute))
113 350 if err != nil {
@@ -118,19 +355,17 @@ func seedLoadRepo(t *testing.T, f *fixture) {
118 355 }
119 356 }
120 357
121 head, err := g.RevParse("refs/heads/feature")
122 if err != nil || head == "" {
123 t.Fatalf("resolve feature: %q, %v", head, err)
124 }
125 req, err := metadata.NewRequest("Load test change",
358 req, err := metadata.NewRequest("Load test change with a large diff",
126 359 strings.Repeat("Why this change. ", 40), "keenan", "main", head, now)
127 360 if err != nil {
128 361 t.Fatal(err)
129 362 }
363 req.HeadRef = "big-change"
130 364 req.Closes = []string{iss.ID}
131 365 if err := metadata.CreateRequest(g, req, "keenan"); err != nil {
132 366 t.Fatal(err)
133 367 }
368 seed.changeID = req.ID
134 369 for i := 0; i < 5; i++ {
135 370 comment, err := metadata.NewRequestComment(fmt.Sprintf("Review note %d.\n", i), "keenan", now.Add(time.Duration(i)*time.Minute))
136 371 if err != nil {
@@ -140,17 +375,14 @@ func seedLoadRepo(t *testing.T, f *fixture) {
140 375 t.Fatal(err)
141 376 }
142 377 }
143 // A second account approves: an author cannot approve their own change, and
144 // the seeded record should look like one that can actually land.
145 378 f.addWriter(t)
146 approver := f.writerID
147 if _, err := metadata.ApproveRequest(g, req.ID, approver, "main", head, now, approver); err != nil {
379 if _, err := metadata.ApproveRequest(g, req.ID, f.writerID, "main", head, now, f.writerID); err != nil {
148 380 t.Fatal(err)
149 381 }
150 382
151 383 // A release with one artifact in the LFS store, so the routes that download
152 384 // one have something real to serve.
153 artifact := []byte(strings.Repeat("artifact bytes\n", 100))
385 artifact := []byte(strings.Repeat("artifact bytes\n", 10000))
154 386 oid, err := f.srv.LFS.PutBytes(f.repo, artifact)
155 387 if err != nil {
156 388 t.Fatal(err)
@@ -165,78 +397,103 @@ func seedLoadRepo(t *testing.T, f *fixture) {
165 397 }, "release: v1", "keenan"); err != nil {
166 398 t.Fatal(err)
167 399 }
400
401 seedSmallRepo(t, f, seed.smallRepo)
402 seedLargeRepo(t, f, seed.largeRepo)
403 return seed
168 404 }
169 405
170 // runCloneScenario drives anonymous shallow clones over the HTTP transport. Each
171 // iteration probes `info/refs` first, so a refusal is reported as the status it
172 // was refused with, then clones the repository.
173 func runCloneScenario(t *testing.T, f *fixture, duration time.Duration, concurrency int) {
406 // seedSmallRepo is the other end of the clone measurement: a repository a clone
407 // finishes quickly, so the small and large numbers are different in the way the
408 // route's cost actually differs.
409 func seedSmallRepo(t *testing.T, f *fixture, name string) {
174 410 t.Helper()
175 base := f.httpURL(f.repo)
176 infoRefs := base + "/info/refs?service=git-upload-pack"
177 client := &http.Client{Timeout: 60 * time.Second}
178 root := t.TempDir()
179
180 results := newLoadResults()
181 peaks := startSampler()
182 deadline := time.Now().Add(duration)
183
184 var wg sync.WaitGroup
185 for i := 0; i < concurrency; i++ {
186 wg.Add(1)
187 go func(worker int) {
188 defer wg.Done()
189 dir := filepath.Join(root, strconv.Itoa(worker))
190 for time.Now().Before(deadline) {
191 start := time.Now()
192 status, err := probe(client, infoRefs)
193 if err == nil {
194 err = cloneShallow(base, dir)
195 }
196 results.record(time.Since(start), status, err)
197 }
198 }(i)
411 m, err := f.srv.Repos.Create(name, f.ownerID, "small load repository")
412 if err != nil {
413 t.Fatal(err)
199 414 }
200 wg.Wait()
201 sampled := peaks.stop()
202 reportLoad(t, "clone", duration, concurrency, results, sampled)
203 }
204
205 // probe records the status of one HTTP request. It is the part of a clone that a
206 // server can refuse, so a load run reports it separately from the git process's
207 // exit status.
208 func probe(client *http.Client, url string) (string, error) {
209 resp, err := client.Get(url)
415 a, err := m.ACL()
210 416 if err != nil {
211 return "error", err
417 t.Fatal(err)
418 }
419 a.Default = "read"
420 if err := m.SetACL(a, "test"); err != nil {
421 t.Fatal(err)
422 }
423 work := workRepo(t, map[string]string{
424 "README.md": "# small\n",
425 "main.go": "package main\n\nfunc main() {}\n",
426 })
427 for i := 0; i < 50; i++ {
428 appendLine(t, filepath.Join(work, "main.go"), fmt.Sprintf("// revision %d\n", i))
429 runGit(t, work, "add", "-A")
430 runGit(t, work, "commit", "-qm", fmt.Sprintf("revision %d", i))
431 }
432 if _, errs, err := f.git(work, "push", f.sshURL(name), "main:refs/heads/main"); err != nil {
433 t.Fatalf("push small: %s", errs)
434 }
435 if err := f.srv.Index.Upsert(m); err != nil {
436 t.Fatal(err)
212 437 }
213 _, _ = io.Copy(io.Discard, resp.Body)
214 resp.Body.Close()
215 return strconv.Itoa(resp.StatusCode), nil
216 438 }
217 439
218 func cloneShallow(url, dir string) error {
219 if err := os.RemoveAll(dir); err != nil {
220 return err
440 // seedLargeRepo writes a repository straight through fast-import: a few thousand
441 // commits and one incompressible blob, so its clone is a real transfer and the
442 // history walk is not trivial.
443 func seedLargeRepo(t *testing.T, f *fixture, name string) {
444 t.Helper()
445 m, err := f.srv.Repos.Create(name, f.ownerID, "large load repository")
446 if err != nil {
447 t.Fatal(err)
221 448 }
222 cmd := exec.Command("git", "clone", "--quiet", "--depth", "1", url, dir)
223 cmd.Env = append(os.Environ(),
224 "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
225 out, err := cmd.CombinedOutput()
449 a, err := m.ACL()
226 450 if err != nil {
227 return fmt.Errorf("clone: %v: %s", err, firstLine(string(out)))
451 t.Fatal(err)
452 }
453 a.Default = "read"
454 if err := m.SetACL(a, "test"); err != nil {
455 t.Fatal(err)
228 456 }
229 return nil
230 }
231 457
232 func firstLine(s string) string {
233 if i := strings.IndexByte(s, '\n'); i >= 0 {
234 s = s[:i]
458 big := make([]byte, loadLargeBlob)
459 rand.New(rand.NewSource(7)).Read(big)
460 var stream bytes.Buffer
461 fmt.Fprintf(&stream, "blob\nmark :1\ndata %d\n%s\n", len(big), big)
462 readme := "large load repository\n"
463 fmt.Fprintf(&stream, "blob\nmark :2\ndata %d\n%s\n", len(readme), readme)
464
465 base := "package demo\n\n// revision 0\nfunc F() {}\n"
466 fmt.Fprintf(&stream, "blob\nmark :3\ndata %d\n%s\n", len(base), base)
467 fmt.Fprintf(&stream, "commit refs/heads/main\n")
468 fmt.Fprintf(&stream, "committer Load <load@example.com> %d +0000\n", 1700000000)
469 msg := "initial"
470 fmt.Fprintf(&stream, "data %d\n%s\n", len(msg), msg)
471 fmt.Fprintf(&stream, "M 100644 :1 big.bin\n")
472 fmt.Fprintf(&stream, "M 100644 :2 README.md\n")
473 fmt.Fprintf(&stream, "M 100644 :3 service.go\n")
474
475 for i := 1; i < loadLargeCommits; i++ {
476 content := fmt.Sprintf("package demo\n\n// revision %d\nfunc F() {}\n", i)
477 mark := 10 + i
478 fmt.Fprintf(&stream, "blob\nmark :%d\ndata %d\n%s\n", mark, len(content), content)
479 fmt.Fprintf(&stream, "commit refs/heads/main\n")
480 fmt.Fprintf(&stream, "committer Load <load@example.com> %d +0000\n", 1700000000+i)
481 msg := fmt.Sprintf("revision %d", i)
482 fmt.Fprintf(&stream, "data %d\n%s\n", len(msg), msg)
483 fmt.Fprintf(&stream, "M 100644 :%d service.go\n", mark)
484 }
485
486 cmd := exec.Command("git", "fast-import", "--quiet")
487 cmd.Dir = m.Dir
488 cmd.Env = append(os.Environ(),
489 "GIT_DIR="+m.Dir, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
490 cmd.Stdin = &stream
491 if out, err := cmd.CombinedOutput(); err != nil {
492 t.Fatalf("fast-import large repo: %v: %s", err, firstLine(string(out)))
235 493 }
236 if len(s) > 120 {
237 s = s[:120]
494 if err := f.srv.Index.Upsert(m); err != nil {
495 t.Fatal(err)
238 496 }
239 return s
240 497 }
241 498
242 499 // loadResults collects one latency sample per request and classifies the
@@ -494,6 +751,16 @@ func appendLine(t *testing.T, path, line string) {
494 751 }
495 752 }
496 753
754 func firstLine(s string) string {
755 if i := strings.IndexByte(s, '\n'); i >= 0 {
756 s = s[:i]
757 }
758 if len(s) > 120 {
759 s = s[:120]
760 }
761 return s
762 }
763
497 764 func envString(name, def string) string {
498 765 if v := strings.TrimSpace(os.Getenv(name)); v != "" {
499 766 return v