@hominis/fireforge 0.31.0 → 0.33.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  9. package/dist/src/commands/patch/split-plan.js +90 -16
  10. package/dist/src/commands/patch/split.js +12 -3
  11. package/dist/src/commands/re-export-files.js +4 -1
  12. package/dist/src/commands/re-export.js +8 -1
  13. package/dist/src/commands/test-run.d.ts +10 -0
  14. package/dist/src/commands/test-run.js +13 -4
  15. package/dist/src/commands/test.js +46 -7
  16. package/dist/src/commands/token.js +12 -1
  17. package/dist/src/commands/typecheck.js +35 -0
  18. package/dist/src/core/build-prepare.js +23 -3
  19. package/dist/src/core/config-validate.js +52 -0
  20. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  21. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  22. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  23. package/dist/src/core/furnace-apply-ftl.js +97 -1
  24. package/dist/src/core/furnace-apply-helpers.js +10 -80
  25. package/dist/src/core/furnace-jsconfig.js +22 -2
  26. package/dist/src/core/git-base.d.ts +15 -0
  27. package/dist/src/core/git-base.js +32 -0
  28. package/dist/src/core/git-diff.d.ts +8 -0
  29. package/dist/src/core/git-diff.js +224 -59
  30. package/dist/src/core/git-file-ops.d.ts +39 -0
  31. package/dist/src/core/git-file-ops.js +82 -1
  32. package/dist/src/core/mach-resource-shim.d.ts +21 -0
  33. package/dist/src/core/mach-resource-shim.js +92 -0
  34. package/dist/src/core/mach.d.ts +17 -0
  35. package/dist/src/core/mach.js +30 -2
  36. package/dist/src/core/manifest-helpers.js +29 -4
  37. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  38. package/dist/src/core/patch-lint-checkjs.js +213 -67
  39. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  40. package/dist/src/core/patch-lint-cross.js +83 -63
  41. package/dist/src/core/patch-lint-css.d.ts +23 -0
  42. package/dist/src/core/patch-lint-css.js +172 -0
  43. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  44. package/dist/src/core/patch-lint-reexports.js +1 -1
  45. package/dist/src/core/patch-lint.d.ts +34 -11
  46. package/dist/src/core/patch-lint.js +19 -163
  47. package/dist/src/core/test-harness-crash.d.ts +6 -3
  48. package/dist/src/core/test-harness-crash.js +32 -4
  49. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  50. package/dist/src/core/test-xpcshell-retry.js +9 -4
  51. package/dist/src/core/token-dark-mode.d.ts +9 -0
  52. package/dist/src/core/token-dark-mode.js +1 -1
  53. package/dist/src/core/token-docs.d.ts +32 -0
  54. package/dist/src/core/token-docs.js +101 -0
  55. package/dist/src/core/token-manager.d.ts +8 -0
  56. package/dist/src/core/token-manager.js +77 -95
  57. package/dist/src/core/token-variant.d.ts +39 -0
  58. package/dist/src/core/token-variant.js +141 -0
  59. package/dist/src/core/typecheck-shim.d.ts +3 -1
  60. package/dist/src/core/typecheck-shim.js +43 -3
  61. package/dist/src/core/typecheck.js +56 -28
  62. package/dist/src/types/commands/options.d.ts +22 -0
  63. package/dist/src/types/config.d.ts +24 -2
  64. package/package.json +3 -3
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Dry-run action planning for custom-component apply. Extracted from
4
+ * `furnace-apply-helpers.ts` so the apply path and its dry-run mirror each
5
+ * stay within the per-file line budget. Consumed only by that module.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { toError } from '../utils/errors.js';
9
+ import { pathExists } from '../utils/fs.js';
10
+ import { describeLocaleFtlJarMnRegistration, describeSharedFtlPrune } from './furnace-apply-ftl.js';
11
+ import { describeFragmentExpansion } from './furnace-css-fragments.js';
12
+ import { validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
13
+ function isRegularFile(entry) {
14
+ if (!entry.isFile())
15
+ return false;
16
+ if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())
17
+ return false;
18
+ return true;
19
+ }
20
+ /** Computes the planned dry-run actions (and pre-flight step errors) for a custom component. */
21
+ export async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
22
+ const actions = [];
23
+ const stepErrors = [];
24
+ for (const entry of entries) {
25
+ if (!isRegularFile(entry))
26
+ continue;
27
+ if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
28
+ continue;
29
+ const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
30
+ actions.push({
31
+ component: name,
32
+ action: fragmentNote ? 'expand-fragments' : 'copy',
33
+ source: join(componentDir, entry.name),
34
+ target: join(targetDir, entry.name),
35
+ description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
36
+ });
37
+ }
38
+ // Per-component .ftl handling is skipped when the component opts into a
39
+ // shared feature-scoped bundle via `sharedFtl`. The shared file is
40
+ // registered (and copied) by whoever owns the feature bundle, so
41
+ // emitting a copy-ftl / register-jar action here would duplicate (or
42
+ // later orphan) the entry.
43
+ if (config.localized && !config.sharedFtl) {
44
+ const ftlFile = `${name}.ftl`;
45
+ const ftlSrc = join(componentDir, ftlFile);
46
+ if (await pathExists(ftlSrc)) {
47
+ actions.push({
48
+ component: name,
49
+ action: 'copy-ftl',
50
+ source: ftlSrc,
51
+ target: join(engineDir, ftlDir, ftlFile),
52
+ description: `Copy ${ftlFile} to ${ftlDir}`,
53
+ });
54
+ const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
55
+ if (localeAction) {
56
+ actions.push(localeAction);
57
+ }
58
+ }
59
+ }
60
+ // A sharedFtl widget owns its strings via the shared bundle; surface the
61
+ // removal of any dangling per-widget locale jar.mn entry so the dry-run
62
+ // plan matches what apply will do (and explains the unblocked build).
63
+ const pruneAction = await describeSharedFtlPrune(engineDir, name, ftlDir, config);
64
+ if (pruneAction) {
65
+ actions.push(pruneAction);
66
+ }
67
+ if (config.register) {
68
+ try {
69
+ const modulePath = `chrome://global/content/elements/${name}.mjs`;
70
+ await validateCustomElementRegistration(engineDir, name, modulePath);
71
+ }
72
+ catch (error) {
73
+ stepErrors.push({
74
+ step: 'customElements.js registration',
75
+ error: toError(error).message,
76
+ });
77
+ }
78
+ actions.push({
79
+ component: name,
80
+ action: 'register-ce',
81
+ description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
82
+ });
83
+ }
84
+ const copiedFileNames = entries
85
+ .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
86
+ .map((entry) => entry.name);
87
+ if (copiedFileNames.length > 0) {
88
+ try {
89
+ await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
90
+ }
91
+ catch (error) {
92
+ stepErrors.push({
93
+ step: 'jar.mn registration',
94
+ error: toError(error).message,
95
+ });
96
+ }
97
+ actions.push({
98
+ component: name,
99
+ action: 'register-jar',
100
+ description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
101
+ });
102
+ }
103
+ return { actions, stepErrors };
104
+ }
105
+ //# sourceMappingURL=furnace-apply-dry-run.js.map
@@ -10,6 +10,18 @@
10
10
  */
11
11
  import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
12
  import { type RollbackJournal } from './furnace-rollback.js';
13
+ /**
14
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
15
+ * records the affected path / step error in the caller's collectors, mirroring
16
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
17
+ */
18
+ export declare function applySharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
19
+ /**
20
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
21
+ * returns an action when a dangling per-widget locale entry exists for a
22
+ * `sharedFtl` widget, else `undefined`.
23
+ */
24
+ export declare function describeSharedFtlPrune(engineDir: string, name: string, ftlDir: string, config: CustomComponentConfig): Promise<DryRunAction | undefined>;
13
25
  /**
14
26
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
15
27
  * in the locale jar.mn.
@@ -11,10 +11,106 @@
11
11
  */
12
12
  import { join, relative } from 'node:path';
13
13
  import { toError } from '../utils/errors.js';
14
- import { copyFile, pathExists } from '../utils/fs.js';
14
+ import { copyFile, pathExists, readText } from '../utils/fs.js';
15
+ import { escapeRegex } from '../utils/regex.js';
15
16
  import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
16
17
  import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
17
18
  import { snapshotFile } from './furnace-rollback.js';
19
+ /**
20
+ * Builds the presence regex for a per-widget locale jar.mn line
21
+ * (`locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl`). Shared by the prune
22
+ * helper and its dry-run describer so both agree on what "dangling" means.
23
+ */
24
+ function perWidgetLocaleEntryPattern(chromeSubPath, tagName) {
25
+ return new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`, 'm');
26
+ }
27
+ /**
28
+ * Resolves the engine-relative locale jar.mn and the per-widget entry regex
29
+ * for a `sharedFtl` widget, or `undefined` when the FTL tree exposes no
30
+ * locale jar.mn we can confidently name.
31
+ */
32
+ function resolveSharedFtlPruneTarget(name, ftlDir) {
33
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
34
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
35
+ if (chromeSubPath === undefined || localeJarRel === undefined)
36
+ return undefined;
37
+ return { localeJarRel, pattern: perWidgetLocaleEntryPattern(chromeSubPath, name) };
38
+ }
39
+ /**
40
+ * Removes a dangling per-widget locale jar.mn entry for a `sharedFtl` widget.
41
+ *
42
+ * A `localized: true` widget that opts into a feature-scoped `sharedFtl`
43
+ * bundle (its strings live under `browser/...` and load via
44
+ * `insertFTLIfNeeded`) must NOT carry a per-widget
45
+ * `locale/@AB_CD@/<chromeSubPath>/<name>.ftl` line. Such a line — written by
46
+ * an older FireForge before the sharedFtl apply guard, or by a layout
47
+ * migration — points at a `.ftl` that does not exist, so `mach build` fails
48
+ * hard (`Cannot find <chromeSubPath>/<name>.ftl`) and blocks every build.
49
+ *
50
+ * The pruned line is the per-widget toolkit entry only; the shared bundle's
51
+ * own line (a different chrome sub-path / base name) is never matched, so
52
+ * pruning one widget cannot orphan the shared bundle. Idempotent: when no
53
+ * dangling entry exists the file is left untouched (no journal churn).
54
+ * Returns the engine-relative jar.mn path when a line was removed, else
55
+ * `undefined`.
56
+ */
57
+ async function pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal) {
58
+ if (!config.sharedFtl)
59
+ return undefined;
60
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
61
+ if (!target)
62
+ return undefined;
63
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
64
+ if (chromeSubPath === undefined)
65
+ return undefined;
66
+ const localeJarAbs = join(engineDir, target.localeJarRel);
67
+ if (!(await pathExists(localeJarAbs)))
68
+ return undefined;
69
+ if (!target.pattern.test(await readText(localeJarAbs)))
70
+ return undefined;
71
+ if (rollbackJournal) {
72
+ await snapshotFile(rollbackJournal, localeJarAbs);
73
+ }
74
+ await removeLocaleFtlJarMnEntry(engineDir, target.localeJarRel, name, chromeSubPath);
75
+ return target.localeJarRel;
76
+ }
77
+ /**
78
+ * Apply-path wrapper around {@link pruneSharedFtlPerWidgetLocaleEntry} that
79
+ * records the affected path / step error in the caller's collectors, mirroring
80
+ * {@link applyCustomFtlFile}'s contract so the main apply helper stays terse.
81
+ */
82
+ export async function applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal) {
83
+ try {
84
+ const prunedPath = await pruneSharedFtlPerWidgetLocaleEntry(engineDir, name, ftlDir, config, rollbackJournal);
85
+ if (prunedPath)
86
+ affectedPaths.push(prunedPath);
87
+ }
88
+ catch (error) {
89
+ stepErrors.push({ step: 'locale jar.mn prune', error: toError(error).message });
90
+ }
91
+ }
92
+ /**
93
+ * Read-only dry-run describer for {@link pruneSharedFtlPerWidgetLocaleEntry}:
94
+ * returns an action when a dangling per-widget locale entry exists for a
95
+ * `sharedFtl` widget, else `undefined`.
96
+ */
97
+ export async function describeSharedFtlPrune(engineDir, name, ftlDir, config) {
98
+ if (!config.sharedFtl)
99
+ return undefined;
100
+ const target = resolveSharedFtlPruneTarget(name, ftlDir);
101
+ if (!target)
102
+ return undefined;
103
+ const localeJarAbs = join(engineDir, target.localeJarRel);
104
+ if (!(await pathExists(localeJarAbs)))
105
+ return undefined;
106
+ if (!target.pattern.test(await readText(localeJarAbs)))
107
+ return undefined;
108
+ return {
109
+ component: name,
110
+ action: 'register-jar',
111
+ description: `Remove dangling per-widget locale entry for ${name} from ${target.localeJarRel} (sharedFtl bundle owns its strings)`,
112
+ };
113
+ }
18
114
  /**
19
115
  * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
20
116
  * in the locale jar.mn.
@@ -6,10 +6,11 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
- import { applyCustomFtlFile, describeLocaleFtlJarMnRegistration, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
9
+ import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
10
+ import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
10
11
  import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
11
- import { deployFileWithFragments, describeFragmentExpansion, SHARED_FRAGMENTS_DIR, } from './furnace-css-fragments.js';
12
- import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnInsertionForFiles, } from './furnace-registration.js';
12
+ import { deployFileWithFragments, SHARED_FRAGMENTS_DIR } from './furnace-css-fragments.js';
13
+ import { addCustomElementRegistration, addJarMnEntries } from './furnace-registration.js';
13
14
  import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
14
15
  import { checkRegistrationConsistency } from './furnace-validate-registration.js';
15
16
  import { isGitRepository } from './git.js';
@@ -278,83 +279,6 @@ export async function hasCustomEngineDrift(root, name, componentDir, config, ftl
278
279
  }
279
280
  return false;
280
281
  }
281
- async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
282
- const actions = [];
283
- const stepErrors = [];
284
- for (const entry of entries) {
285
- if (!isRegularFile(entry))
286
- continue;
287
- if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
288
- continue;
289
- const fragmentNote = await describeFragmentExpansion(join(componentDir, entry.name));
290
- actions.push({
291
- component: name,
292
- action: fragmentNote ? 'expand-fragments' : 'copy',
293
- source: join(componentDir, entry.name),
294
- target: join(targetDir, entry.name),
295
- description: `Copy ${entry.name} to ${config.targetPath}${fragmentNote}`,
296
- });
297
- }
298
- // Per-component .ftl handling is skipped when the component opts into a
299
- // shared feature-scoped bundle via `sharedFtl`. The shared file is
300
- // registered (and copied) by whoever owns the feature bundle, so
301
- // emitting a copy-ftl / register-jar action here would duplicate (or
302
- // later orphan) the entry.
303
- if (config.localized && !config.sharedFtl) {
304
- const ftlFile = `${name}.ftl`;
305
- const ftlSrc = join(componentDir, ftlFile);
306
- if (await pathExists(ftlSrc)) {
307
- actions.push({
308
- component: name,
309
- action: 'copy-ftl',
310
- source: ftlSrc,
311
- target: join(engineDir, ftlDir, ftlFile),
312
- description: `Copy ${ftlFile} to ${ftlDir}`,
313
- });
314
- const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
315
- if (localeAction) {
316
- actions.push(localeAction);
317
- }
318
- }
319
- }
320
- if (config.register) {
321
- try {
322
- const modulePath = `chrome://global/content/elements/${name}.mjs`;
323
- await validateCustomElementRegistration(engineDir, name, modulePath);
324
- }
325
- catch (error) {
326
- stepErrors.push({
327
- step: 'customElements.js registration',
328
- error: toError(error).message,
329
- });
330
- }
331
- actions.push({
332
- component: name,
333
- action: 'register-ce',
334
- description: `Register ${name} in customElements.js (DOMContentLoaded block)`,
335
- });
336
- }
337
- const copiedFileNames = entries
338
- .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
339
- .map((entry) => entry.name);
340
- if (copiedFileNames.length > 0) {
341
- try {
342
- await validateJarMnInsertionForFiles(engineDir, name, copiedFileNames);
343
- }
344
- catch (error) {
345
- stepErrors.push({
346
- step: 'jar.mn registration',
347
- error: toError(error).message,
348
- });
349
- }
350
- actions.push({
351
- component: name,
352
- action: 'register-jar',
353
- description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
354
- });
355
- }
356
- return { actions, stepErrors };
357
- }
358
282
  /** Applies a custom component into the engine tree and captures registration step errors. */
359
283
  export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal, applyOptions = {}) {
360
284
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -406,6 +330,12 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
406
330
  if (config.localized && !config.sharedFtl) {
407
331
  await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
408
332
  }
333
+ else if (config.localized && config.sharedFtl) {
334
+ // Drop any dangling per-widget locale jar.mn entry that would point at a
335
+ // non-existent `<chromeSubPath>/<name>.ftl` and fail `mach build`. The
336
+ // shared bundle (a different chrome path/base name) is never touched.
337
+ await applySharedFtlPrune(engineDir, name, ftlDir, config, affectedPaths, stepErrors, rollbackJournal);
338
+ }
409
339
  if (config.register) {
410
340
  try {
411
341
  const modulePath = `chrome://global/content/elements/${name}.mjs`;
@@ -51,12 +51,28 @@ async function computeDesiredChromePathEntries(config, customDir, jsconfigAbsPat
51
51
  for (const file of files.sort()) {
52
52
  if (!file.endsWith('.mjs'))
53
53
  continue;
54
- const sourcePath = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
54
+ // Emit a `./`-prefixed relative value. TypeScript treats a bare
55
+ // `paths` value (`moz-widget/moz-widget.mjs`) as non-relative and
56
+ // rejects it without `baseUrl` (TS5090); a `./`-prefixed value
57
+ // resolves against the jsconfig directory with no `baseUrl` (which
58
+ // TS6 deprecates, TS5101). `../`-prefixed paths are already relative
59
+ // and left untouched.
60
+ const rel = normalizePathSlashes(relative(jsconfigDir, join(componentDir, file)));
61
+ const sourcePath = rel.startsWith('.') ? rel : `./${rel}`;
55
62
  entries[`${CHROME_ELEMENTS_URL_PREFIX}${file}`] = [sourcePath];
56
63
  }
57
64
  }
58
65
  return entries;
59
66
  }
67
+ /**
68
+ * Compares two `paths` values treating a leading `./` as insignificant, so
69
+ * the reconciler does not churn between `./x` and bare `x` forms (either
70
+ * direction). Used to decide whether a managed entry is stale.
71
+ */
72
+ function samePathValue(a, b) {
73
+ const strip = (p) => (p.startsWith('./') ? p.slice(2) : p);
74
+ return strip(a) === strip(b);
75
+ }
60
76
  /** True when `key`/`value` is a Furnace-managed chrome-elements mapping. */
61
77
  function isManagedEntry(key, value, jsconfigDir, customDir) {
62
78
  if (!key.startsWith(CHROME_ELEMENTS_URL_PREFIX))
@@ -119,7 +135,11 @@ export async function syncFurnaceJsconfigPaths(root, config, options) {
119
135
  result.pruned.push(key);
120
136
  continue;
121
137
  }
122
- if (value[0] !== want[0]) {
138
+ // Treat `./x` and bare `x` as equal so a previously-synced bare value (or
139
+ // a hand-written `./` prefix) is not rewritten as "stale" on every run.
140
+ // The existing value is kept verbatim when equivalent — no churn either
141
+ // way; only a genuinely different target updates (to the `./` form).
142
+ if (!samePathValue(value[0] ?? '', want[0] ?? '')) {
123
143
  result.updated.push(key);
124
144
  nextPaths[key] = want;
125
145
  }
@@ -63,6 +63,21 @@ export declare function git(args: string[], cwd: string, options?: {
63
63
  timeout?: number;
64
64
  env?: Record<string, string>;
65
65
  }): Promise<string>;
66
+ /**
67
+ * Splits a pathspec list into chunks whose joined byte length stays well under
68
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
69
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
70
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
71
+ * leaves room for the fixed git arguments plus the inherited environment.
72
+ *
73
+ * Chunk boundaries are output-neutral for every batched caller here: each
74
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
75
+ * how the paths are grouped across invocations never affects the result.
76
+ * @param paths - Pathspecs to chunk
77
+ * @param budgetBytes - Maximum joined byte length per chunk
78
+ * @returns Path chunks, each safe to pass as a single argv tail
79
+ */
80
+ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): string[][];
66
81
  /**
67
82
  * Configures git performance settings for large trees.
68
83
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -72,6 +72,38 @@ export async function git(args, cwd, options) {
72
72
  }
73
73
  return result.stdout;
74
74
  }
75
+ /**
76
+ * Splits a pathspec list into chunks whose joined byte length stays well under
77
+ * the OS `ARG_MAX` limit, so a single batched `git` invocation over hundreds of
78
+ * Mozilla-length paths cannot fail with `E2BIG`. The 96 KB budget is
79
+ * deliberately conservative — even the smallest historical `ARG_MAX` (256 KB)
80
+ * leaves room for the fixed git arguments plus the inherited environment.
81
+ *
82
+ * Chunk boundaries are output-neutral for every batched caller here: each
83
+ * caller merges the per-chunk results into a single Set/Map keyed by path, so
84
+ * how the paths are grouped across invocations never affects the result.
85
+ * @param paths - Pathspecs to chunk
86
+ * @param budgetBytes - Maximum joined byte length per chunk
87
+ * @returns Path chunks, each safe to pass as a single argv tail
88
+ */
89
+ export function chunkPathspecs(paths, budgetBytes = 96_000) {
90
+ const chunks = [];
91
+ let current = [];
92
+ let used = 0;
93
+ for (const path of paths) {
94
+ const cost = Buffer.byteLength(path) + 1;
95
+ if (current.length > 0 && used + cost > budgetBytes) {
96
+ chunks.push(current);
97
+ current = [];
98
+ used = 0;
99
+ }
100
+ current.push(path);
101
+ used += cost;
102
+ }
103
+ if (current.length > 0)
104
+ chunks.push(current);
105
+ return chunks;
106
+ }
75
107
  /**
76
108
  * Configures git performance settings for large trees.
77
109
  * Enables index preloading, untracked cache, and the manyFiles feature
@@ -40,6 +40,14 @@ export declare function getAllDiff(repoDir: string): Promise<string>;
40
40
  * Builds a combined diff against HEAD for the provided files without touching
41
41
  * the real git index. Tracked files use `git diff HEAD`; untracked files use
42
42
  * synthesized new-file diffs.
43
+ *
44
+ * Performance: the work is batched into a handful of `git` invocations
45
+ * (one `ls-tree` to classify, one `diff` over all tracked files, one
46
+ * `hash-object` over all new text files) rather than the ~2 spawns per file the
47
+ * previous per-file loop issued — that fan-out dominated the cold-run cost on a
48
+ * Firefox-sized checkout (~700 serial spawns, ~99s). Binary, directory, and
49
+ * recursion paths stay per-file because they are rare and (for binary) mutate
50
+ * the index.
43
51
  * @param repoDir - Repository directory
44
52
  * @param files - File paths to diff (relative to repo root)
45
53
  * @returns Combined diff content