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.
Read a change's file list without its patch, and never cut a diff
f64c77e4950c1759f336d3bc1849ead86e8b8ebf · Keenan Nemetz · 8d ago · parent 11720250
Read a change's file list without its patch, and never cut a diff The review tree, the file count and the landing summary were all parsed out of one `git diff` capped at 512 KiB. When a change carries a large file the cut lands inside it, and every file named after the cut is missing from the tree: reviewing #143, a 546 KB deletion hid the 296 files that came after it while the tab still said "6 changed". The cap existed to stop a page rendering every hunk, but the pages it guarded already render one file at a time. The inventory and the patch are separate reads now. DiffInventory runs `git diff --raw --numstat -z`, which names every file with its status and line counts in one walk of the trees and generates no hunks; the changed-file tree, the counts and the landing summary read that. The Files tab then asks for the one file it shows with DiffPaths, and the commit page keeps its per-file ShowPatchPath. Nothing is capped by bytes: a page is as large as the file it shows, and the inventory is the whole change however large it is. Compare still renders every file of a range at once, and is uncapped too, so a large comparison is one large page. Making that per-file is the next step if it turns out to matter. TestDiffInventoryCountsEveryFile covers the statuses, counts, renames and binary files the inventory has to report; TestRequestFilesListsEveryChangedFile is the regression for the tree that lost its files, and the commit page test now asserts the end of a large patch instead of its cut.
core/git/git.go +180−19
| @@ -1285,21 +1285,57 @@ func (r *Repo) TreeBlobs(treeish string) (map[string]Blob, error) { | |||
| 1285 | 1285 | return blobs, nil | |
| 1286 | 1286 | } | |
| 1287 | 1287 | ||
| 1288 | // Diff returns the unified diff git prints between two revisions, capped at | ||
| 1289 | // maxBytes. A range may be written with git's own syntax ("main...feature"), so | ||
| 1290 | // the caller decides between two-dot and merge-base semantics. | ||
| 1291 | func (r *Repo) Diff(rangeOrFrom, to string, maxBytes int) (string, bool, error) { | ||
| 1292 | args := []string{"diff", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3"} | ||
| 1293 | if to == "" { | ||
| 1294 | args = append(args, rangeOrFrom) | ||
| 1295 | } else { | ||
| 1296 | args = append(args, rangeOrFrom, to) | ||
| 1288 | // Diff returns the unified diff git prints between two revisions. A range may | ||
| 1289 | // be written with git's own syntax ("main...feature"), so the caller decides | ||
| 1290 | // between two-dot and merge-base semantics. | ||
| 1291 | // | ||
| 1292 | // The patch is complete. A view that renders one file asks for that file | ||
| 1293 | // (DiffPaths) and a view that lists files asks for their names and counts | ||
| 1294 | // (DiffInventory), so no caller has to cut a patch to stay small: the size of a | ||
| 1295 | // page follows the file it shows, not the size of the change. | ||
| 1296 | func (r *Repo) Diff(rangeOrFrom, to string) (string, error) { | ||
| 1297 | args := append([]string{"diff", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3"}, diffRange(rangeOrFrom, to)...) | ||
| 1298 | out, errs, err := r.run(nil, args...) | ||
| 1299 | if err != nil { | ||
| 1300 | return "", fmt.Errorf("diff: %w: %s", err, errs) | ||
| 1301 | } | ||
| 1302 | return string(out), nil | ||
| 1303 | } | ||
| 1304 | |||
| 1305 | // DiffPaths is Diff limited to the paths given, which is what a per-file view | ||
| 1306 | // reads. A rename needs both of its paths named: filtered to the destination | ||
| 1307 | // alone, git reports what is left as a new file and the rename is lost. | ||
| 1308 | func (r *Repo) DiffPaths(rangeOrFrom, to string, paths []string) (string, error) { | ||
| 1309 | args := append([]string{"diff", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3"}, diffRange(rangeOrFrom, to)...) | ||
| 1310 | args = append(append(args, "--"), paths...) | ||
| 1311 | out, errs, err := r.run(nil, args...) | ||
| 1312 | if err != nil { | ||
| 1313 | return "", fmt.Errorf("diff %s: %w: %s", strings.Join(paths, " "), err, errs) | ||
| 1314 | } | ||
| 1315 | return string(out), nil | ||
| 1316 | } | ||
| 1317 | |||
| 1318 | // DiffInventory lists what a change touches without generating its patch: the | ||
| 1319 | // status, both paths, and the line counts of every file. It is what a changed- | ||
| 1320 | // file tree, a landing summary and a file count read, so a change touching a | ||
| 1321 | // thousand files costs one walk of the trees rather than a patch large enough to | ||
| 1322 | // hold every hunk — and nothing is cut, so the inventory is the whole change. | ||
| 1323 | // | ||
| 1324 | // `--raw --numstat` prints the raw records first and the numstat records second, | ||
| 1325 | // both NUL-separated because a path may hold a tab, a quote or a non-ASCII byte. | ||
| 1326 | // The two sections are printed in the same order, which is what pairs a status | ||
| 1327 | // with its counts. | ||
| 1328 | func (r *Repo) DiffInventory(rangeOrFrom, to string) ([]PathChange, error) { | ||
| 1329 | args := append([]string{"diff", "--raw", "--numstat", "--find-renames", "-z"}, diffRange(rangeOrFrom, to)...) | ||
| 1330 | out, errs, err := r.run(nil, args...) | ||
| 1331 | if err != nil { | ||
| 1332 | return nil, fmt.Errorf("diff --raw --numstat: %w: %s", err, errs) | ||
| 1297 | 1333 | } | |
| 1298 | out, errs, err := r.runHead(maxBytes, args...) | ||
| 1334 | changes, err := parseInventory(string(out)) | ||
| 1299 | 1335 | if err != nil { | |
| 1300 | return "", false, fmt.Errorf("diff: %w: %s", err, errs) | ||
| 1336 | return nil, err | ||
| 1301 | 1337 | } | |
| 1302 | return string(out), len(out) == maxBytes, nil | ||
| 1338 | return changes, nil | ||
| 1303 | 1339 | } | |
| 1304 | 1340 | ||
| 1305 | 1341 | // ShowPatchPath is a commit's patch against its parent (or the empty tree for a | |
| @@ -1307,17 +1343,136 @@ func (r *Repo) Diff(rangeOrFrom, to string, maxBytes int) (string, bool, error) | |||
| 1307 | 1343 | // which is why it is used here instead of assembling a range by hand. | |
| 1308 | 1344 | // | |
| 1309 | 1345 | // It is what a per-file commit view reads, so opening a broad commit costs the | |
| 1310 | // hunks of one file rather than the whole diff. A rename needs both of its paths | ||
| 1311 | // named: filtered to the destination alone, git reports what is left as a new | ||
| 1312 | // file, and the view would lose the rename. | ||
| 1313 | func (r *Repo) ShowPatchPath(rev string, paths []string, maxBytes int) (string, bool, error) { | ||
| 1346 | // hunks of one file rather than the whole diff. | ||
| 1347 | func (r *Repo) ShowPatchPath(rev string, paths []string) (string, error) { | ||
| 1314 | 1348 | args := []string{"show", "--format=", "--patch", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3", rev, "--"} | |
| 1315 | 1349 | args = append(args, paths...) | |
| 1316 | out, errs, err := r.runHead(maxBytes, args...) | ||
| 1350 | out, errs, err := r.run(nil, args...) | ||
| 1351 | if err != nil { | ||
| 1352 | return "", fmt.Errorf("show %s (%s): %w: %s", rev, strings.Join(paths, " "), err, errs) | ||
| 1353 | } | ||
| 1354 | return string(out), nil | ||
| 1355 | } | ||
| 1356 | |||
| 1357 | // diffRange returns the revision arguments a diff call takes. One range string | ||
| 1358 | // is git's own "a...b" syntax; two revisions are the endpoints git diffs when a | ||
| 1359 | // landing record names the tip it landed on rather than a range. | ||
| 1360 | func diffRange(rangeOrFrom, to string) []string { | ||
| 1361 | if to == "" { | ||
| 1362 | return []string{rangeOrFrom} | ||
| 1363 | } | ||
| 1364 | return []string{rangeOrFrom, to} | ||
| 1365 | } | ||
| 1366 | |||
| 1367 | // parseInventory pairs the two sections `git diff --raw --numstat -z` prints. | ||
| 1368 | func parseInventory(out string) ([]PathChange, error) { | ||
| 1369 | changes, stats, err := parseInventorySections(strings.Split(out, "\x00")) | ||
| 1317 | 1370 | if err != nil { | |
| 1318 | return "", false, fmt.Errorf("show %s (%s): %w: %s", rev, strings.Join(paths, " "), err, errs) | ||
| 1371 | return nil, err | ||
| 1372 | } | ||
| 1373 | if len(stats) != len(changes) { | ||
| 1374 | return nil, fmt.Errorf("diff --raw --numstat: %d files but %d count records", len(changes), len(stats)) | ||
| 1375 | } | ||
| 1376 | for i := range changes { | ||
| 1377 | // The sections are printed in the same order; checking that they agree | ||
| 1378 | // is what keeps a count from landing on the wrong file. | ||
| 1379 | if pathLabel(changes[i]) != pathLabel(stats[i]) { | ||
| 1380 | return nil, fmt.Errorf("diff --raw --numstat: %q and %q disagree", pathLabel(changes[i]), pathLabel(stats[i])) | ||
| 1381 | } | ||
| 1382 | changes[i].Added, changes[i].Removed = stats[i].Added, stats[i].Removed | ||
| 1383 | changes[i].Binary = stats[i].Binary | ||
| 1384 | } | ||
| 1385 | return changes, nil | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | // parseInventorySections reads the raw records, then returns them beside the | ||
| 1389 | // numstat records that follow. | ||
| 1390 | func parseInventorySections(fields []string) ([]PathChange, []PathChange, error) { | ||
| 1391 | var changes []PathChange | ||
| 1392 | // A raw record is ":<oldmode> <newmode> <oldsha> <newsha> <status>" and then | ||
| 1393 | // one path, or two for a rename or a copy. The status carries a similarity | ||
| 1394 | // score for those (R073), so only its first byte names the change. | ||
| 1395 | for len(fields) > 0 && strings.HasPrefix(fields[0], ":") { | ||
| 1396 | record := fields[0] | ||
| 1397 | fields = fields[1:] | ||
| 1398 | status := record[strings.LastIndexByte(record, ' ')+1:] | ||
| 1399 | if status == "" { | ||
| 1400 | return nil, nil, fmt.Errorf("diff --raw: %q has no status", record) | ||
| 1401 | } | ||
| 1402 | change := PathChange{Status: status[:1]} | ||
| 1403 | if change.Status == "R" || change.Status == "C" { | ||
| 1404 | if len(fields) < 2 { | ||
| 1405 | return nil, nil, fmt.Errorf("diff --raw: %q is missing a path", record) | ||
| 1406 | } | ||
| 1407 | change.Old, change.New = fields[0], fields[1] | ||
| 1408 | fields = fields[2:] | ||
| 1409 | } else { | ||
| 1410 | if len(fields) < 1 { | ||
| 1411 | return nil, nil, fmt.Errorf("diff --raw: %q is missing a path", record) | ||
| 1412 | } | ||
| 1413 | change.Old, change.New = fields[0], fields[0] | ||
| 1414 | fields = fields[1:] | ||
| 1415 | switch change.Status { | ||
| 1416 | case "A": | ||
| 1417 | change.Old = "" | ||
| 1418 | case "D": | ||
| 1419 | change.New = "" | ||
| 1420 | } | ||
| 1421 | } | ||
| 1422 | changes = append(changes, change) | ||
| 1423 | } | ||
| 1424 | |||
| 1425 | var stats []PathChange | ||
| 1426 | // A numstat record is "<added>\t<removed>\t<path>". Binary files have no | ||
| 1427 | // lines, and a rename leaves the path empty and prints its two paths as the | ||
| 1428 | // two records that follow the counts. | ||
| 1429 | for len(fields) > 0 { | ||
| 1430 | record := fields[0] | ||
| 1431 | fields = fields[1:] | ||
| 1432 | if record == "" { | ||
| 1433 | continue | ||
| 1434 | } | ||
| 1435 | added, rest, ok := strings.Cut(record, "\t") | ||
| 1436 | if !ok { | ||
| 1437 | return nil, nil, fmt.Errorf("diff --numstat: %q is not counts and a path", record) | ||
| 1438 | } | ||
| 1439 | removed, path, ok := strings.Cut(rest, "\t") | ||
| 1440 | if !ok { | ||
| 1441 | return nil, nil, fmt.Errorf("diff --numstat: %q is not counts and a path", record) | ||
| 1442 | } | ||
| 1443 | stat := PathChange{} | ||
| 1444 | if added == "-" || removed == "-" { | ||
| 1445 | stat.Binary = true | ||
| 1446 | } else { | ||
| 1447 | var err error | ||
| 1448 | if stat.Added, err = strconv.Atoi(added); err != nil { | ||
| 1449 | return nil, nil, fmt.Errorf("diff --numstat: %q has no added count", record) | ||
| 1450 | } | ||
| 1451 | if stat.Removed, err = strconv.Atoi(removed); err != nil { | ||
| 1452 | return nil, nil, fmt.Errorf("diff --numstat: %q has no removed count", record) | ||
| 1453 | } | ||
| 1454 | } | ||
| 1455 | if path == "" { | ||
| 1456 | if len(fields) < 2 { | ||
| 1457 | return nil, nil, fmt.Errorf("diff --numstat: %q names no paths", record) | ||
| 1458 | } | ||
| 1459 | stat.Old, stat.New = fields[0], fields[1] | ||
| 1460 | fields = fields[2:] | ||
| 1461 | } else { | ||
| 1462 | stat.New = path | ||
| 1463 | } | ||
| 1464 | stats = append(stats, stat) | ||
| 1465 | } | ||
| 1466 | return changes, stats, nil | ||
| 1467 | } | ||
| 1468 | |||
| 1469 | // pathLabel is the path a change is named by: the new one, or the old one for a | ||
| 1470 | // deletion. | ||
| 1471 | func pathLabel(c PathChange) string { | ||
| 1472 | if c.New != "" { | ||
| 1473 | return c.New | ||
| 1319 | 1474 | } | |
| 1320 | return string(out), len(out) == maxBytes, nil | ||
| 1475 | return c.Old | ||
| 1321 | 1476 | } | |
| 1322 | 1477 | ||
| 1323 | 1478 | // PathChange is one path a commit touched, as git's name-status reports it. Old | |
| @@ -1332,6 +1487,12 @@ type PathChange struct { | |||
| 1332 | 1487 | Status string | |
| 1333 | 1488 | Old string | |
| 1334 | 1489 | New string | |
| 1490 | // Added and Removed are the line counts of the change to this path, and | ||
| 1491 | // Binary says there are no lines to count. They are zero for a listing that | ||
| 1492 | // did not ask for them (ShowNameStatus). | ||
| 1493 | Added int | ||
| 1494 | Removed int | ||
| 1495 | Binary bool | ||
| 1335 | 1496 | } | |
| 1336 | 1497 | ||
| 1337 | 1498 | // ShowNameStatus lists the paths one commit touched without generating its | |