@vernikr/size-report 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/bin/size.js +6 -0
- package/package.json +61 -0
- package/src/artifact.css +8 -0
- package/src/artifact.js +21 -0
- package/src/check.js +142 -0
- package/src/cli.js +476 -0
- package/src/config.js +158 -0
- package/src/css.js +37 -0
- package/src/data.js +105 -0
- package/src/derived.js +115 -0
- package/src/doctor.js +225 -0
- package/src/explain.js +113 -0
- package/src/git.js +221 -0
- package/src/history.js +206 -0
- package/src/hook.js +404 -0
- package/src/journal.js +66 -0
- package/src/locales.js +124 -0
- package/src/metrics.js +268 -0
- package/src/minify.js +67 -0
- package/src/optional.js +31 -0
- package/src/page/app.css +165 -0
- package/src/page/app.js +560 -0
- package/src/page/build.js +102 -0
- package/src/parse-worker.js +34 -0
- package/src/parse.js +136 -0
- package/src/refusal.js +133 -0
- package/src/render.js +131 -0
- package/src/size-table.js +86 -0
- package/src/strip.js +231 -0
- package/src/table.css +35 -0
- package/src/tokens.js +76 -0
- package/src/tool.js +24 -0
- package/templates/README.md +79 -0
- package/templates/ci.yml +67 -0
- package/templates/size-report.config.json +53 -0
package/src/git.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
2
|
+
import { EXIT, refuse } from './refusal.js';
|
|
3
|
+
|
|
4
|
+
/* Единственная граница вызова git: закрепления настроек, блобы пачкой, история и
|
|
5
|
+
* сверка с диском. Всё, что инструмент знает о содержимом репозитория, приходит
|
|
6
|
+
* отсюда, — поэтому и закрепления задаются здесь, а не в каждом вызове. */
|
|
7
|
+
|
|
8
|
+
export const MAX_BUF = 256 * 1024 * 1024;
|
|
9
|
+
const FIELD = '\u0001'; // разделитель полей в формате git log
|
|
10
|
+
|
|
11
|
+
/* Всё, что движок читает у git, читается с явно заданными настройками: их
|
|
12
|
+
* значения по умолчанию берутся из настроек машины и меняют то, что попадает в
|
|
13
|
+
* разбор. Без `core.quotePath=false` не-английские пути приходят закавыченными и
|
|
14
|
+
* экранированными (`"docs/\320\267..."`): колонка с таким путём не находит файла,
|
|
15
|
+
* а коммит, у которого она была единственным изменением объёма, теряет строку.
|
|
16
|
+
* Остальные закрепления закрывают тот же класс — раскраска и блок подписи
|
|
17
|
+
* подмешались бы в разбираемый поток, а перекодировка подписей — в подписи строк
|
|
18
|
+
* отчёта. Закрепление задаётся здесь, а не в каждом вызове: иначе его забудет
|
|
19
|
+
* следующий вызов.
|
|
20
|
+
*
|
|
21
|
+
* Локаль закрепляется заодно: разбор не должен зависеть от того, какие переводы
|
|
22
|
+
* стоят на машине. Цена — сообщения самого git в неожиданных отказах идут
|
|
23
|
+
* по-английски; сообщения инструмента остаются русскими. */
|
|
24
|
+
export const GIT_PINS = [
|
|
25
|
+
'core.quotePath=false',
|
|
26
|
+
'color.ui=never',
|
|
27
|
+
'log.showSignature=false',
|
|
28
|
+
'i18n.logOutputEncoding=UTF-8'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function gitArgv(args) {
|
|
32
|
+
const out = ['--no-pager'];
|
|
33
|
+
GIT_PINS.forEach((pin) => { out.push('-c', pin); });
|
|
34
|
+
return out.concat(args);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function gitEnv() {
|
|
38
|
+
return Object.assign({}, process.env, { LC_ALL: 'C', LANG: 'C' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function git(root, args) {
|
|
42
|
+
return execFileSync('git', gitArgv(args), {
|
|
43
|
+
cwd: root, encoding: 'utf8', maxBuffer: MAX_BUF, env: gitEnv()
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* То же чтение, но с кодом возврата: там, где ненулевой код — ожидаемый ответ, а не
|
|
48
|
+
* отказ (`git diff --quiet` отвечает 1 на расхождение). Исключение здесь означало бы
|
|
49
|
+
* отказ инструмента там, где задан простой вопрос. `env` досыпается к окружению
|
|
50
|
+
* границы — им хук собирает коммит отчёта в отдельном индексе, не трогая
|
|
51
|
+
* настоящий (см. `src/hook.js`). */
|
|
52
|
+
export function gitTry(root, args, env) {
|
|
53
|
+
const res = spawnSync('git', gitArgv(args), {
|
|
54
|
+
cwd: root, encoding: 'utf8', maxBuffer: MAX_BUF,
|
|
55
|
+
env: Object.assign(gitEnv(), env || {})
|
|
56
|
+
});
|
|
57
|
+
return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Чтение блобов пачкой. `git cat-file --batch-check` отвечает про список пар
|
|
61
|
+
* `ревизия:путь` (sha объекта и размер), `--batch` отдаёт содержимое. Один-два
|
|
62
|
+
* процесса на всю историю вместо спавна `git show` на каждый файл — на тысячах
|
|
63
|
+
* коммитов это разница между минутами и секундой. Побочно размер объекта
|
|
64
|
+
* оказывается дешевле его чтения: метрике `raw` содержимое не нужно вовсе.
|
|
65
|
+
*
|
|
66
|
+
* Ответы позиционные (строка на запрос), поэтому запросы и ответы сопоставляются
|
|
67
|
+
* по порядку — `ревизия:путь` git в ответе не повторяет. */
|
|
68
|
+
const BLOB_CHUNK = 1000; // спек на пачку: ограничивает и stdin, и память
|
|
69
|
+
|
|
70
|
+
function catFileCheck(root, specs) {
|
|
71
|
+
const out = execFileSync('git', gitArgv(['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)']), {
|
|
72
|
+
cwd: root, encoding: 'utf8', input: specs.join('\n') + '\n', maxBuffer: MAX_BUF, env: gitEnv()
|
|
73
|
+
});
|
|
74
|
+
return out.split('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function catFileBatch(root, shas) {
|
|
78
|
+
const buf = execFileSync('git', gitArgv(['cat-file', '--batch']), {
|
|
79
|
+
cwd: root, input: shas.join('\n') + '\n', maxBuffer: MAX_BUF, env: gitEnv()
|
|
80
|
+
});
|
|
81
|
+
const out = new Map();
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < buf.length) {
|
|
84
|
+
const nl = buf.indexOf(10, i);
|
|
85
|
+
if (nl < 0) break;
|
|
86
|
+
const f = buf.toString('utf8', i, nl).split(' ');
|
|
87
|
+
i = nl + 1;
|
|
88
|
+
if (f.length < 3) continue; // «<спека> missing»
|
|
89
|
+
const size = Number(f[2]);
|
|
90
|
+
out.set(f[0], { size: size, text: buf.toString('utf8', i, i + size) });
|
|
91
|
+
i += size + 1; // перевод строки после содержимого
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Блобы для списка пар «ревизия:путь». `needText` — читать ли содержимое: метрике
|
|
97
|
+
* `raw` хватает размера объекта, и тогда `--batch` не вызывается вовсе.
|
|
98
|
+
* Одинаковые спеки и одинаковые блобы запрашиваются один раз (кэш по sha). */
|
|
99
|
+
export function readBlobs(root, specs, needText) {
|
|
100
|
+
const uniq = [...new Set(specs)];
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const texts = new Map(); // sha блоба → содержимое
|
|
103
|
+
for (let start = 0; start < uniq.length; start += BLOB_CHUNK) {
|
|
104
|
+
const part = uniq.slice(start, start + BLOB_CHUNK);
|
|
105
|
+
const lines = catFileCheck(root, part);
|
|
106
|
+
const want = [];
|
|
107
|
+
part.forEach((spec, i) => {
|
|
108
|
+
const line = lines[i] === undefined ? '' : lines[i];
|
|
109
|
+
if (line === '' || line.slice(-8) === ' missing') return;
|
|
110
|
+
const f = line.split(' ');
|
|
111
|
+
if (f.length < 3 || f[1] !== 'blob') return;
|
|
112
|
+
out.set(spec, { sha: f[0], size: Number(f[2]), text: null });
|
|
113
|
+
if (texts.has(f[0])) return;
|
|
114
|
+
want.push(f[0]);
|
|
115
|
+
});
|
|
116
|
+
if (!needText || want.length === 0) continue;
|
|
117
|
+
catFileBatch(root, [...new Set(want)]).forEach((b, sha) => { texts.set(sha, b.text); });
|
|
118
|
+
}
|
|
119
|
+
if (needText) out.forEach((b) => { if (texts.has(b.sha)) b.text = texts.get(b.sha); });
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Дерево HEAD: sha блобов всех файлов коммита. Это правда о содержимом HEAD,
|
|
124
|
+
* добытая не тем же способом, что состояние движка (то читает блобы пачкой),
|
|
125
|
+
* поэтому расхождение с ней и означает потерянную при переносе правку. Один вызов
|
|
126
|
+
* на прогон; разбор идёт по NUL (`-z`), иначе пути с пробелами пришлось бы
|
|
127
|
+
* раскодировать. */
|
|
128
|
+
export function headTree(root) {
|
|
129
|
+
const out = new Map();
|
|
130
|
+
const tree = execFileSync('git', gitArgv(['ls-tree', '-r', '-z', 'HEAD']), {
|
|
131
|
+
cwd: root, encoding: 'utf8', maxBuffer: MAX_BUF, env: gitEnv()
|
|
132
|
+
});
|
|
133
|
+
tree.split('\u0000').forEach((rec) => {
|
|
134
|
+
const tab = rec.indexOf('\t');
|
|
135
|
+
if (tab < 0) return;
|
|
136
|
+
out.set(rec.slice(tab + 1), rec.slice(0, tab).split(' ')[2]);
|
|
137
|
+
});
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Файлы на диске — такими, какими их видит git: `hash-object` пропускает каждый
|
|
142
|
+
* файл через те же переводы строк и фильтры, что и `git add` (`.gitattributes`,
|
|
143
|
+
* `core.autocrlf`). Поэтому «файл на диске соответствует коммиту» — это сравнение
|
|
144
|
+
* хешей, а не размеров: размер зависит от выкладки (при `core.autocrlf=true` на
|
|
145
|
+
* диске CRLF, в git LF). Пути приходят списком, ответы позиционные — как у
|
|
146
|
+
* `cat-file`. */
|
|
147
|
+
export function diskHashes(root, paths) {
|
|
148
|
+
const out = new Map();
|
|
149
|
+
const lines = execFileSync('git', gitArgv(['hash-object', '--stdin-paths']), {
|
|
150
|
+
cwd: root, encoding: 'utf8', input: paths.join('\n') + '\n', maxBuffer: MAX_BUF, env: gitEnv()
|
|
151
|
+
}).split('\n');
|
|
152
|
+
paths.forEach((p, i) => { out.set(p, lines[i] === undefined ? '' : lines[i].trim()); });
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Обратный перевод: то, что git выложил бы на диск для блоба этой ревизии и пути
|
|
157
|
+
* (`--filters` применяет фильтры выкладки). Нужен там, где переводы строк git не
|
|
158
|
+
* возвращает обратно: файл, в котором CRLF лежат в самом коммите, при
|
|
159
|
+
* `core.autocrlf=true` выкладывается как есть, а «очистка» вернула бы LF, — сам
|
|
160
|
+
* git про такие файлы предупреждает, а на диск кладёт именно это. */
|
|
161
|
+
export function diskForm(root, rev, p) {
|
|
162
|
+
return execFileSync('git', gitArgv(['cat-file', '--filters', rev + ':' + p]), {
|
|
163
|
+
cwd: root, maxBuffer: MAX_BUF, env: gitEnv()
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Содержимое файла в ревизии или null, если файла там нет.
|
|
168
|
+
export function blobAt(root, rev, p) {
|
|
169
|
+
const blobs = readBlobs(root, [rev + ':' + p], true);
|
|
170
|
+
const blob = blobs.get(rev + ':' + p);
|
|
171
|
+
return blob === undefined ? null : blob.text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Имя ревизии → sha коммита: `HEAD`, ветка, тег, `HEAD~1`, короткий или полный
|
|
175
|
+
* sha. Правила имён остаются за git, а не переписываются здесь: свои разошлись бы
|
|
176
|
+
* с ним на первом же `main~2` или `HEAD@{1}`, а `^{commit}` отсекает имена, ведущие
|
|
177
|
+
* не к коммиту (тег на блоб, путь в дереве). Имя, начинающееся с дефиса, к git не
|
|
178
|
+
* идёт вовсе: в `rev-parse` оно было бы ключом, а не ревизией.
|
|
179
|
+
* Не разрешилось — `null`: «имени нет» и «имя неоднозначно» разбирает вызывающий,
|
|
180
|
+
* у которого для этого есть список коммитов. */
|
|
181
|
+
export function resolveCommit(root, name) {
|
|
182
|
+
if (name === '' || name.charAt(0) === '-') return null;
|
|
183
|
+
const res = gitTry(root, ['rev-parse', '--verify', '--quiet', name + '^{commit}']);
|
|
184
|
+
const sha = res.stdout.trim();
|
|
185
|
+
return res.status === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* История одним вызовом: заголовок коммита и список изменённых им путей.
|
|
189
|
+
* `%ad` — дата автора в его собственной зоне (не в зоне машины), иначе таблица
|
|
190
|
+
* собиралась бы в CI по UTC и расходилась бы с локальной сборкой.
|
|
191
|
+
* `--diff-merges=first-parent` — иначе у merge-коммита списка путей нет вовсе
|
|
192
|
+
* (git не показывает дифф слияния, пока не попросишь): правки разрешения
|
|
193
|
+
* конфликта выпали бы и из строки, и из переноса состояния, а состояние на HEAD
|
|
194
|
+
* разошлось бы с содержимым файла в дереве. С первым родителем у слияния видно
|
|
195
|
+
* ровно то, что оно привнесло поверх своей ветки. */
|
|
196
|
+
export function readHistory(root) {
|
|
197
|
+
const log = git(root, [
|
|
198
|
+
'log', '--reverse', '--name-only', '--diff-merges=first-parent', '--date=format:%Y-%m-%d %H:%M',
|
|
199
|
+
'--pretty=format:commit' + FIELD + '%H' + FIELD + '%ad' + FIELD + '%P' + FIELD + '%s'
|
|
200
|
+
]);
|
|
201
|
+
const commits = [];
|
|
202
|
+
let cur = null;
|
|
203
|
+
log.split('\n').forEach((line) => {
|
|
204
|
+
if (line.indexOf('commit' + FIELD) === 0) {
|
|
205
|
+
const f = line.split(FIELD);
|
|
206
|
+
cur = { sha: f[1], when: f[2], parents: f[3] ? f[3].split(' ') : [], subject: f[4], files: [] };
|
|
207
|
+
commits.push(cur);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (line !== '' && cur) cur.files.push(line);
|
|
211
|
+
});
|
|
212
|
+
return commits;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function assertFullHistory(root) {
|
|
216
|
+
if (git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true') {
|
|
217
|
+
refuse(EXIT.SHALLOW, 'история обрезана (shallow clone): таблица строится по всей истории коммитов.\n'
|
|
218
|
+
+ ' локально: git fetch --unshallow\n'
|
|
219
|
+
+ ' в CI: actions/checkout с fetch-depth: 0');
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { assertFullHistory, diskForm, diskHashes, git, headTree, readBlobs, readHistory } from './git.js';
|
|
4
|
+
import { METRICS, measureBlob, pointExact } from './metrics.js';
|
|
5
|
+
import { touchedSection } from './journal.js';
|
|
6
|
+
import { EXIT, refuse } from './refusal.js';
|
|
7
|
+
|
|
8
|
+
/* Обход истории: измерение по коммитам, сдвиг чисел, перенос состояния между
|
|
9
|
+
* коммитами, сверка с рабочим деревом и сборка — та единственная точка, из
|
|
10
|
+
* которой состояние и строки попадают наружу. Верхний этаж чтения: ниже — git и
|
|
11
|
+
* метрики, выше — только уже собранные значения. */
|
|
12
|
+
|
|
13
|
+
/* Причина, по которой коммит не получил строки, — ключ, а не текст: по нему и
|
|
14
|
+
* считается сводка, и отвечает `explain`. Слова для человека — в `skipLine`, и
|
|
15
|
+
* они те же, что были строкой раньше: «без изменения объёма» накрывает и тот
|
|
16
|
+
* случай, когда коммит не тронул ни одного файла колонок, — в отчёте эта разница
|
|
17
|
+
* не проводилась, и эталон контракта её сохраняет; отличие видно в `explain`, где
|
|
18
|
+
* оно и нужно. */
|
|
19
|
+
const SKIP_WORDS = { merge: 'merge', report: 'только таблица', flat: 'без изменения объёма' };
|
|
20
|
+
|
|
21
|
+
export function skipLine(dropped) {
|
|
22
|
+
return dropped.sha.slice(0, 7) + ' (' + SKIP_WORDS[dropped.reason] + ')';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Сдвинул ли коммит хотя бы одно число. Сравниваются числа, а не список файлов:
|
|
26
|
+
* правка в пробелах или комментариях размера не меняет, и строка про неё была бы
|
|
27
|
+
* пустой, а у слияния клетки выходят нулевыми всегда, когда разрешение конфликта
|
|
28
|
+
* совпало с тем, что уже дала ветка. Колонка, которой коммит не касался,
|
|
29
|
+
* остаётся тем же объектом состояния. */
|
|
30
|
+
function changesVolume(state, before, columns, metrics) {
|
|
31
|
+
return columns.some((_col, i) => {
|
|
32
|
+
const a = state[i], b = before[i];
|
|
33
|
+
if (a === b) return false;
|
|
34
|
+
if (a === null || b === null) return true;
|
|
35
|
+
return metrics.some((m) => a.cells[m] !== b.cells[m]);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Проход по истории. Состояние колонки переносится вперёд, а перезамер делается
|
|
40
|
+
* только для изменившихся в коммите файлов. Читается всё сразу: сначала план
|
|
41
|
+
* «какие пары ревизия:путь понадобятся», затем один поход в git (`readBlobs`),
|
|
42
|
+
* затем собственно измерение — иначе на каждый коммит приходилось бы по
|
|
43
|
+
* git-вызову на колонку. Значения метрик кэшируются по sha блоба: ревизия с тем
|
|
44
|
+
* же содержимым (откат, повторный merge) не пересчитывается.
|
|
45
|
+
*
|
|
46
|
+
* `known` — уже прочитанная история: проходам, которым она нужна ещё и сама по
|
|
47
|
+
* себе (полнота покрытия), незачем звать `git log` второй раз. */
|
|
48
|
+
export function measureHistory(cfg, root, known) {
|
|
49
|
+
const commits = known === undefined ? readHistory(root) : known;
|
|
50
|
+
const metrics = cfg.metrics;
|
|
51
|
+
// Текст журнала нужен всегда: ссылка в раздел — не метрика, но тоже чтение.
|
|
52
|
+
const needText = !!cfg.journal || metrics.some((m) => METRICS[m].needsText);
|
|
53
|
+
const skipPaths = [cfg.output].concat(cfg.skip || []);
|
|
54
|
+
const state = cfg.columns.map(() => null);
|
|
55
|
+
const rows = [];
|
|
56
|
+
const dropped = [];
|
|
57
|
+
const mixed = [];
|
|
58
|
+
let journalPrev = '';
|
|
59
|
+
|
|
60
|
+
const plan = commits.map((c) => {
|
|
61
|
+
const changed = new Set(c.files);
|
|
62
|
+
/* Псевдонимов колонки, которых коммит коснулся, может быть и два: при
|
|
63
|
+
* выключенном распознавании переименований git отдаёт в одном коммите и старое
|
|
64
|
+
* имя, и новое. Собираются все — какой из них в коммите действительно есть,
|
|
65
|
+
* решается потом, по прочитанным блобам. */
|
|
66
|
+
const picks = cfg.columns.map((col) => col.paths
|
|
67
|
+
.filter((cand) => changed.has(cand))
|
|
68
|
+
.map((path) => ({ path: path, spec: c.sha + ':' + path })));
|
|
69
|
+
const journal = cfg.journal && changed.has(cfg.journal.path) ? c.sha + ':' + cfg.journal.path : null;
|
|
70
|
+
return { picks: picks, journal: journal };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const specs = [];
|
|
74
|
+
plan.forEach((p) => {
|
|
75
|
+
p.picks.forEach((candidates) => { candidates.forEach((cand) => specs.push(cand.spec)); });
|
|
76
|
+
if (p.journal !== null) specs.push(p.journal);
|
|
77
|
+
});
|
|
78
|
+
const blobs = readBlobs(root, specs, needText);
|
|
79
|
+
|
|
80
|
+
const measured = new Map(); // sha блоба + метрика → число
|
|
81
|
+
const measure = (name, blob, file, rev) => {
|
|
82
|
+
const key = blob.sha + '\u0000' + name;
|
|
83
|
+
if (measured.has(key)) return measured.get(key);
|
|
84
|
+
const value = measureBlob(name, blob, file, cfg, rev);
|
|
85
|
+
measured.set(key, value);
|
|
86
|
+
return value;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
commits.forEach((c, ci) => {
|
|
90
|
+
let section = null;
|
|
91
|
+
if (plan[ci].journal !== null) {
|
|
92
|
+
const journalBlob = blobs.get(plan[ci].journal);
|
|
93
|
+
if (journalBlob !== undefined) {
|
|
94
|
+
section = touchedSection(journalPrev, journalBlob.text, cfg.journal.pattern);
|
|
95
|
+
journalPrev = journalBlob.text;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const before = state.slice();
|
|
100
|
+
plan[ci].picks.forEach((candidates, i) => {
|
|
101
|
+
/* Из псевдонимов берётся тот, который в коммите есть, а не первый по
|
|
102
|
+
* порядку настроек: исчезнувшее имя в коммите отсутствует, и состояние,
|
|
103
|
+
* взятое по порядку, теряло файл (а сверка с деревом — отказывала). */
|
|
104
|
+
const pick = candidates.find((cand) => blobs.get(cand.spec) !== undefined);
|
|
105
|
+
if (pick === undefined) {
|
|
106
|
+
// Путь в коммите есть, а файла по нему нет — файл удалён.
|
|
107
|
+
if (candidates.length > 0) state[i] = null;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const blob = blobs.get(pick.spec);
|
|
111
|
+
const cells = {};
|
|
112
|
+
/* Приближённость числа — свойство пути, а не блоба: от расширения зависит,
|
|
113
|
+
* возьмёт ли формат минификатор. Поэтому она считается здесь, вместо с
|
|
114
|
+
* замером, и в кэш содержимого не попадает. */
|
|
115
|
+
const approx = {};
|
|
116
|
+
metrics.forEach((m) => {
|
|
117
|
+
cells[m] = measure(m, blob, pick.path, c.sha);
|
|
118
|
+
approx[m] = !pointExact(m, pick.path, cfg);
|
|
119
|
+
});
|
|
120
|
+
state[i] = { path: pick.path, sha: blob.sha, cells: cells, approx: approx };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (c.parents.length > 1 && !cfg.rows.merges) { dropped.push({ sha: c.sha, reason: 'merge' }); return; }
|
|
124
|
+
if (c.files.length > 0 && c.files.every((f) => skipPaths.indexOf(f) >= 0)) {
|
|
125
|
+
dropped.push({ sha: c.sha, reason: 'report' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!changesVolume(state, before, cfg.columns, metrics)) {
|
|
129
|
+
dropped.push({ sha: c.sha, reason: 'flat' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (c.files.some((f) => f === cfg.output)) mixed.push(c.sha.slice(0, 7));
|
|
133
|
+
|
|
134
|
+
rows.push({
|
|
135
|
+
sha: c.sha,
|
|
136
|
+
when: c.when,
|
|
137
|
+
subject: c.subject,
|
|
138
|
+
section: section,
|
|
139
|
+
cells: state.map((s) => (s === null ? null : s.cells)),
|
|
140
|
+
approx: state.map((s) => (s === null ? null : s.approx))
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return { rows, dropped, mixed, state };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Сверка с рабочим деревом отвечает на два вопроса, и оба обязательны: состояние
|
|
148
|
+
* движка на HEAD совпадает с деревом коммита, и файл на диске соответствует тому
|
|
149
|
+
* же содержимому. Первый ловит правку, потерянную при переносе состояния между
|
|
150
|
+
* коммитами (например, у merge-коммита, которого нет в списке изменённых путей): и
|
|
151
|
+
* потерянное создание файла (в дереве он есть, а состояние о нём не знает), и
|
|
152
|
+
* потерянное изменение (файл есть с обеих сторон, содержимое разное), и потерянное
|
|
153
|
+
* удаление (состояние о файле знает, а в дереве его нет). Сравнение при этом идёт
|
|
154
|
+
* с расхождением, а не с пустотой: колонка, чей файл жил в истории и был удалён до
|
|
155
|
+
* HEAD, пуста с обеих сторон — это не потеря, а её видно в отчёте. Второй вопрос —
|
|
156
|
+
* правка, которой в истории нет вовсе. Размеры для этого не годятся: на диске они
|
|
157
|
+
* зависят от выкладки (при `core.autocrlf=true` — значение по умолчанию в установке
|
|
158
|
+
* Git для Windows — CRLF против LF), и инструмент отказывался работать там, где всё
|
|
159
|
+
* в порядке. Файлы, изменённые в дереве, из сверки с диском выпадают: их
|
|
160
|
+
* содержимое в коммите и на диске различается законно. */
|
|
161
|
+
function assertMatchesDisk(state, cfg, root) {
|
|
162
|
+
const dirty = new Set(git(root, ['status', '--porcelain']).split('\n')
|
|
163
|
+
.map((l) => l.trim()).filter((l) => l !== '').map((l) => l.replace(/^\S+\s+/, '').replace(/^.* -> /, '')));
|
|
164
|
+
const tree = headTree(root);
|
|
165
|
+
const clean = [];
|
|
166
|
+
cfg.columns.forEach((col, i) => {
|
|
167
|
+
const s = state[i];
|
|
168
|
+
const aliases = col.paths.filter((alias) => tree.has(alias));
|
|
169
|
+
const p = s === null ? aliases[0] : s.path;
|
|
170
|
+
const inTree = p === undefined ? undefined : tree.get(p);
|
|
171
|
+
const lost = s === null ? aliases.length > 0 : inTree !== s.sha;
|
|
172
|
+
if (lost) {
|
|
173
|
+
refuse(EXIT.VIOLATION, 'состояние «' + col.label + '» на HEAD не совпало с деревом коммита (в дереве '
|
|
174
|
+
+ (aliases.length === 0 ? 'файла нет'
|
|
175
|
+
: aliases.map((alias) => alias + ' ' + tree.get(alias).slice(0, 7)).join(', '))
|
|
176
|
+
+ ', в состоянии ' + (s === null ? 'файла нет' : s.path + ' ' + s.sha.slice(0, 7))
|
|
177
|
+
+ '): перенос состояния между коммитами пропустил правку'
|
|
178
|
+
// Пересборка здесь не починка: состояние считается тем же прогоном, и
|
|
179
|
+
// устаревшей таблицы в этом расхождении нет. Поэтому совет называет не
|
|
180
|
+
// команду починки, а то, чем это можно показать.
|
|
181
|
+
+ '\n починка: пересборкой это не лечится — расхождение в самом переносе состояния,'
|
|
182
|
+
+ ' а не в таблице. Разбор: git show HEAD:' + p);
|
|
183
|
+
}
|
|
184
|
+
if (p !== undefined && !dirty.has(p) && clean.indexOf(p) < 0) clean.push(p);
|
|
185
|
+
});
|
|
186
|
+
if (clean.length === 0) return;
|
|
187
|
+
const onDisk = diskHashes(root, clean);
|
|
188
|
+
clean.forEach((p) => {
|
|
189
|
+
if (onDisk.get(p) === tree.get(p)) return; // git считает файл неизменным
|
|
190
|
+
if (fs.readFileSync(path.join(root, p)).equals(diskForm(root, 'HEAD', p))) return; // переводы строк необратимы
|
|
191
|
+
refuse(EXIT.VIOLATION, 'содержимое ' + p + ' на диске разошлось с HEAD (' + onDisk.get(p).slice(0, 7)
|
|
192
|
+
+ ' вместо ' + tree.get(p).slice(0, 7) + '), хотя git не считает файл изменённым: правка есть только на диске'
|
|
193
|
+
+ '\n починка: закоммитьте правку или откатите её: git checkout -- ' + p);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function build(cfg, root) {
|
|
198
|
+
assertFullHistory(root);
|
|
199
|
+
const measured = measureHistory(cfg, root);
|
|
200
|
+
assertMatchesDisk(measured.state, cfg, root);
|
|
201
|
+
if (measured.mixed.length > 0) {
|
|
202
|
+
console.error('! таблицу обновляли вместе с кодом: ' + measured.mixed.join(', ')
|
|
203
|
+
+ ' — так строка коммита не может попасть в сам коммит; обновляйте таблицу отдельным коммитом.');
|
|
204
|
+
}
|
|
205
|
+
return measured;
|
|
206
|
+
}
|