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.

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