Технологии
«2200 файлов и больше миллиона строк»: GitHub пересобрала diff в приложении Copilot ради гигантских pull request
23 сентября GitHub опубликовала технический разбор того, как команда приложения Copilot перестроила экран diff, чтобы открывать pull request на миллион изменённых строк с сотнями встроенных комментариев ревью. Разбор написал Alberto Gimeno, Principal Design Engineer приложения Copilot. Самый большой pull request, который команда смогла найти, — открытый: 2200 файлов, больше миллиона изменённых строк и больше 400 комментариев ревью внутри diff.

23 сентября GitHub опубликовала технический разбор того, как команда приложения Copilot перестроила экран diff, чтобы открывать pull request на миллион изменённых строк с сотнями встроенных комментариев ревью. Разбор написал Alberto Gimeno, Principal Design Engineer приложения Copilot. Самый большой pull request, который команда смогла найти, — открытый: 2200 файлов, больше миллиона изменённых строк и больше 400 комментариев ревью внутри diff.
Почему большой pull request нельзя просто разбить
Широкие рефакторинги и миграции часто приходится вливать одним изменением, а stacked pull request хотя и помогают делить работу на меньшие части и снижают риск, не всё делится чисто — и разговор в ревью только увеличивает размер одного pull request.
С рендерингом самого diff проблемы нет: строки виртуализируются, смонтированный DOM остаётся маленьким, а каждая строка — это строка кода известной высоты. Сложность начинается там, где в diff появляется обсуждение: высота комментария зависит от того, как переносится его markdown, от раскрываемых блоков, от наличия поля ответа и от того, успели ли загрузиться картинки — и всё это выясняется только во время отрисовки. Эти проблемы проявляются под нагрузкой, на конкретном движке и на конкретной позиции прокрутки.
Стандартный ответ на миллион узлов — виртуализация: монтируются только строки на экране плюс небольшой запас, а те же элементы DOM переиспользуются при прокрутке. Такое поведение возможно, пока кто-то поставляет геометрию: высота скроллбара — это сумма высот всех строк, позиция строки N — сумма высот строк над ней. Пока каждая строка — код известной высоты при известном размере шрифта, всю таблицу высот можно посчитать заранее, и она никогда не меняется; GitHub называет это контрактом «все высоты известны до первой отрисовки». На чистом коде геометрия точна и не перестраивается, а работа на кадр не растёт с общим числом строк.
Две геометрии вместо одной
Резервирование фиксированной высоты под каждый комментарий по оценке на большом pull request рассыпается: оценка, верная в среднем, ошибается на крайних случаях — у большинства комментариев остаётся пустая полоса, а дорогие по высоте обрезаются или получают вложенный скроллбар. Запись настоящей высоты после отрисовки в общую таблицу смещений сдвигает всё, что ниже, пока пользователь уже прокручивает, и на большом pull request это большой скачок. Отсюда вывод: для комментариев контракт «все высоты известны до первой отрисовки» недостижим, а обещать можно другое — высоты ограничены, измеряются лениво, а коррекции малы и привязаны к тому, на что смотрит пользователь.
Решение — перестать заставлять одну геометрию обслуживать два разных типа контента. Высота документа раскладывается на детерминированную высоту кода, точную и известную заранее, сумму эффективных высот динамических блоков, которые сначала оцениваются, а потом измеряются, и запас прокрутки. Динамический блок — это тред ревью, черновик или поле ответа; он опознаётся по своему содержимому, а не по текущей позиции: у него устойчивый ключ, переживающий загрузку контента, и он привязан к файлу, строке и стороне, а не к пиксельной координате, поэтому перекомпоновка не может его потерять. Число таких блоков ограничено числом комментариев, а не строк. Эффективная высота блока — измеренная, если она валидна, закэшированная, если совпали отпечаток содержимого и ширина, и оценка во всех остальных случаях; хранится это отдельно от строк кода, поэтому изменяющийся комментарий не заставляет пересобирать геометрию кода.
Измерение без петли обратной связи
Первый вариант измерения был очевидным и оказался неправильным в поучительном смысле: один ResizeObserver на блок, который следит за элементом и записывает измеренную высоту обратно в layout при каждом изменении. От этой схемы отказались на этапе performance-харденинга: наблюдатель, который пишет высоту в layout элемента, за которым следит, может перезапускать сам себя, а цена растёт с каждым смонтированным блоком.
Что вышло вместо этого — единый проход измерений, привязанный к простою и прокрутке. Он запускается, когда видимый диапазон устоялся, а не на каждом кадре прокрутки, и полностью ждёт, пока прокрутка идёт: перекомпоновка посреди скролла — ровно тот jank, которого команда избегала. Кандидатами становятся только блоки примерно в пределах 2400 пикселей от viewport, поэтому работа линейна по viewport, а далёкие блоки продолжают жить на оценке и корректируются по мере приближения. Смонтированный блок на экране — источник истины: его отрисованная высота считывается пакетом, в один reflow без записей между чтениями, и он никогда не пропускается в пользу устаревшей оценки. Для близкого блока, который ещё не смонтирован, допускается не больше одной внеэкранной отрисовки, а блоки выше viewport пропускают и это: их переоценка скрыта ниже сгиба, и платить за отрисовку не стоит.
Наблюдатель остался, но с другой ролью: по умолчанию он только помечает блок, чтобы проход по простою перечитал его, и никогда не пишет высоту сам — именно это замкнуло бы отвергнутую петлю. Исключение сделано для изменений, которые вызвал сам пользователь: раскрытие details, открытие поля ответа, догрузка картинки. Когда блок смонтирован и на экране, наблюдатель измеряет его и применяет коррекцию в том же кадре до отрисовки: блок вырастает, код перепозиционируется, и всё ниже сдвигается вместе. От превращения в петлю это защищают два ограничения — не больше одной синхронной фиксации на кадр, чтобы серия изменений схлопывалась в одну, и никогда во время активной прокрутки, где коррекция возвращается в пакетный проход.
Якорение прокрутки и ошибка, которая «укусила»
Когда измеренная высота расходится с оценкой, меняется арифметика скроллбара, и наивный результат — скачок viewport. Лечение — корректировать по идентичности, а не по пикселю: перед применением дельт захватывается то, за что зацепился пользователь, строка или блок плюс смещение внутри, затем тот же якорь разрешается в новую пиксельную позицию, и прокрутка выставляется так, чтобы якорь остался на месте. Дальше работают правила: блок выше viewport изменился — компенсировать дельтой; контент гидратируется ниже viewport — не компенсировать; если в видимом блоке раскрыли details или открыли ответ, коррекция выше этого блока подавляется, чтобы взаимодействие ощущалось прямым, а контент ниже просто утекал вниз; с активной инерцией указателя или колеса не спорить, а коррекцию применять пакетом после кадра.
У последнего правила есть острая грань. «Не корректировать, пока пользователь прокручивает» было реализовано как проверка последнего наблюдённого скролла, а программные прокрутки тоже обновляли эту метку времени. Переключение бокового дерева файлов меняет ширину панели diff: при включённом переносе строк все перенесённые строки выше пересчитываются в другое число визуальных строк, всё координатное пространство сдвигается, и поверхность выдаёт небольшой собственный скролл, пока устаканивается. Защита принимала его за пользовательский и пропускала именно ту коррекцию, которая должна была удержать место, — файл, который читал человек, уезжал с экрана. Исправление — отличать пользовательскую прокрутку от той, которую поверхность вызвала сама: любая проверка «взаимодействует ли пользователь» должна быть такой, чтобы её не могли удовлетворить собственные побочные эффекты проверяющего кода.
Что происходит под поверхностью
Первый принцип со стороны данных — сначала структура, потом контент. Diff запрашивается инкрементально, поэтому дерево файлов и метаданные отрисовываются, пока документ ещё грузится, а полный набор тредов ревью разрешается заранее, а не подтягивается по одному.
Второй — откладывать работу над элементом, пока она не понадобится: подсветка синтаксиса идёт вне главного потока, поэтому строки сразу видны как обычный текст и окрашиваются, когда приходят результаты, а большие markdown-тела и контекст suggested change не строятся до приближения к viewport. Третий — про то, какие расходы стоит сохранять: освобождать diff-документ при уходе правильно, потому что такие документы большие, но оболочка вокруг diff остаётся, и мгновенно нарисованная оболочка вокруг пустого diff выглядит сломанной, даже если ждать в сумме меньше. Поэтому политику оставили и добавили кэш: последние несколько diff остаются резидентными, всё сверх этого вытесняется, а фоновая перезагрузка замечает, что документ устарел.
Автономный цикл «изменить — измерить — улучшить»
Почти каждый баг в проекте был невидим, пока не становился видимым, а воспроизвести его вручную мучительно: типичный отчёт звучит как «полоса пустого места под некоторыми комментариями, но только иногда, только на больших pull request, и проходит, если прокрутить мимо и обратно». Поэтому поверхность несёт постоянные структурированные пробы, которые отвечают на вопросы о самой себе: привязана ли она к viewport и сколько строк и блоков смонтировано сейчас; коалесцируются ли измерения в одну фиксацию на кадр и сколько занимает кадр; насколько велики коррекции прокрутки; не вставился ли блок комментария после начала прокрутки — после того как топология с бэкенда пришла, это должно быть ноль; снимаются ли наблюдатели при unmount или на каждый блок течёт утечка. Это объективные сигналы pass/fail, закреплённые как бюджеты в end-to-end тесте на синтетическом фикстуре — огромном pull request с множеством комментариев.
Центральной частью стал автономный цикл «изменить — измерить — улучшить» в двух полосах. Headless-полоса прогоняет декларативный сценарий — открыть pull request, прокрутить до доли, переключить блок details, изменить размер окна — против mock-сервера, читая собственную production-инструментацию приложения: счётчики рендеров React, performance timeline и сэмплер requestAnimationFrame для jank. Так как сценарий — это просто JSON, переданный пробнику во время работы, агент может профилировать любой поток, описав его обычным языком, без правки исходного кода. Автопилот гоняет настоящее десктопное приложение по сценарию большого pull request без человека: сначала холодный старт, когда комментарии ещё скелетоны, потом тёплый с загруженными комментариями, переключение блоков details, открытие и отмена полей ответа, сворачивание и разворачивание файлов, переключение дерева в сайдбаре, глубокий проход по списку файлов, изменение размера окна. Каждое измерение зеркалится в лог на диске, поэтому агент читает поведение рантайма, когда за клавиатурой никого нет. Тёплый сэмпл считается здоровым только при трёх условиях: между комментариями нет незаполненных промежутков, нет пустых блоков комментариев и реальное содержимое тредов действительно смонтировано по всему диапазону прокрутки, включая глубокий проход по файлам.
Сам цикл состоит из четырёх шагов: воспроизвести без человека на реальном движке и прочитать лог с диска; детектировать по health-сигналу, а не глазами, доверяя полям сэмпла; поставить одну узкую структурированную пробу в подозрительном шве — правка поверхности hot-reload'ит живое окно и перевооружает автопилот, так что свежий захват примерно в одном цикле; и убрать леса, закрепив понятый инвариант в тесте и дизайн-документе, чтобы остались только сигналы детекторного класса.
Что в итоге
Ревью такого большого pull request раньше означало одно из двух: ждать или сдаться и читать его в другом месте, потому что ревью — не документ с известными размерами, а разговор, который меняет форму, пока его читаешь, и поверхность под ним нужно строить под это с самого начала, а не приделывать потом. Результат — экран pull request, где diff на миллион строк с сотнями тредов открывается, прокручивается и ведёт себя как pull request обычного размера: комментарии рендерятся полностью, а не обрезаются в прокручиваемый блок, раскрытие свёрнутой секции двигает код под ней и ничего больше, а возврат в только что покинутый pull request ставит на то же место.