@hominis/fireforge 0.26.0 → 0.27.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 +12 -0
- package/README.md +1 -1
- package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
- package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
- package/dist/src/commands/doctor.js +3 -0
- package/dist/src/commands/download.js +4 -1
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/re-export-files.js +44 -26
- package/dist/src/commands/re-export.js +53 -14
- package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
- package/dist/src/commands/rebase/conflict-summary.js +38 -0
- package/dist/src/commands/rebase/patch-loop.js +24 -6
- package/dist/src/commands/setup-support.js +6 -2
- package/dist/src/commands/setup.js +1 -0
- package/dist/src/commands/source.d.ts +9 -0
- package/dist/src/commands/source.js +92 -0
- package/dist/src/commands/verify.js +27 -0
- package/dist/src/core/branding.js +54 -7
- package/dist/src/core/config-validate.js +1 -1
- package/dist/src/core/firefox-extract.d.ts +1 -1
- package/dist/src/core/firefox-extract.js +13 -1
- package/dist/src/core/firefox.d.ts +2 -1
- package/dist/src/core/firefox.js +2 -2
- package/dist/src/core/furnace-registration-ast.js +32 -4
- package/dist/src/core/furnace-registration-validate.d.ts +7 -0
- package/dist/src/core/furnace-registration-validate.js +48 -6
- package/dist/src/core/furnace-validate-registration.js +8 -17
- package/dist/src/core/git.js +46 -16
- package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
- package/dist/src/core/patch-artifact-normalize.js +13 -0
- package/dist/src/core/patch-export-update.js +2 -1
- package/dist/src/core/patch-export.js +3 -2
- package/dist/src/core/status-classify.js +19 -4
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +22 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/utils/elapsed.d.ts +4 -0
- package/dist/src/utils/elapsed.js +15 -0
- package/dist/src/utils/validation.d.ts +2 -2
- package/dist/src/utils/validation.js +5 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
- Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
|
|
6
|
+
- Improved `download --force` git indexing progress with phase, count, and heartbeat output.
|
|
7
|
+
- Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
|
|
8
|
+
- Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
|
|
9
|
+
- Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
|
|
10
|
+
- Preserved patch-owned branding `configure.sh` settings during build preflight.
|
|
11
|
+
- Added custom element registration support for Furnace validate/apply and Firefox 152-style array-backed ESM registrations.
|
|
12
|
+
- Normalized generated patch artifacts so blank context lines do not trip raw whitespace checks.
|
|
13
|
+
- Improved rebase conflict summaries and added `doctor --post-rebase-audit` for common registration surfaces.
|
|
14
|
+
|
|
3
15
|
## 0.26.0
|
|
4
16
|
|
|
5
17
|
- Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Use `fireforge --help` for the full set of commands.
|
|
|
68
68
|
When Mozilla publishes a new ESR you need to update the configured Firefox version, download the new source code and reapply the patches:
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
npx fireforge
|
|
71
|
+
npx fireforge source set --version 145.0.0esr --product firefox-esr --sha256 <archive-sha256>
|
|
72
72
|
npx fireforge download --force
|
|
73
73
|
npx fireforge rebase
|
|
74
74
|
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { pathExists, readText } from '../../utils/fs.js';
|
|
5
|
+
import { ok, warning } from '../doctor-check-core.js';
|
|
6
|
+
async function readEngineText(engineDir, relativePath) {
|
|
7
|
+
const fullPath = join(engineDir, relativePath);
|
|
8
|
+
if (!(await pathExists(fullPath)))
|
|
9
|
+
return null;
|
|
10
|
+
return readText(fullPath);
|
|
11
|
+
}
|
|
12
|
+
async function collectBrowserTomlFiles(root) {
|
|
13
|
+
const testRoot = join(root, 'browser/base/content/test');
|
|
14
|
+
if (!(await pathExists(testRoot)))
|
|
15
|
+
return [];
|
|
16
|
+
const result = [];
|
|
17
|
+
async function walk(absDir, relDir) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(absDir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
27
|
+
const absPath = join(absDir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
await walk(absPath, relPath);
|
|
30
|
+
}
|
|
31
|
+
else if (entry.isFile() && entry.name === 'browser.toml') {
|
|
32
|
+
result.push(`browser/base/content/test/${relPath}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await walk(testRoot, '');
|
|
37
|
+
return result.sort();
|
|
38
|
+
}
|
|
39
|
+
async function runPostRebaseAudit(ctx) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
const engineDir = ctx.paths.engine;
|
|
42
|
+
const mozConfigure = await readEngineText(engineDir, 'browser/moz.configure');
|
|
43
|
+
if (mozConfigure === null) {
|
|
44
|
+
issues.push('browser/moz.configure is missing');
|
|
45
|
+
}
|
|
46
|
+
else if (!mozConfigure.includes('BROWSER_CHROME_URL')) {
|
|
47
|
+
issues.push('browser/moz.configure does not mention BROWSER_CHROME_URL');
|
|
48
|
+
}
|
|
49
|
+
const browserJar = await readEngineText(engineDir, 'browser/base/jar.mn');
|
|
50
|
+
if (browserJar === null) {
|
|
51
|
+
issues.push('browser/base/jar.mn is missing');
|
|
52
|
+
}
|
|
53
|
+
else if (!/\.xhtml\b/.test(browserJar)) {
|
|
54
|
+
issues.push('browser/base/jar.mn has no chrome document .xhtml entries');
|
|
55
|
+
}
|
|
56
|
+
const customElements = await readEngineText(engineDir, 'toolkit/content/customElements.js');
|
|
57
|
+
if (customElements === null) {
|
|
58
|
+
issues.push('toolkit/content/customElements.js is missing');
|
|
59
|
+
}
|
|
60
|
+
else if (!customElements.includes('customElements')) {
|
|
61
|
+
issues.push('toolkit/content/customElements.js does not contain customElements registrations');
|
|
62
|
+
}
|
|
63
|
+
const toolkitJar = await readEngineText(engineDir, 'toolkit/content/jar.mn');
|
|
64
|
+
if (toolkitJar === null) {
|
|
65
|
+
issues.push('toolkit/content/jar.mn is missing');
|
|
66
|
+
}
|
|
67
|
+
else if (!toolkitJar.includes('content/global/widgets/') &&
|
|
68
|
+
!toolkitJar.includes('content/global/elements/')) {
|
|
69
|
+
issues.push('toolkit/content/jar.mn has no widget/element exposure entries');
|
|
70
|
+
}
|
|
71
|
+
const browserTomls = await collectBrowserTomlFiles(engineDir);
|
|
72
|
+
if (browserTomls.length === 0) {
|
|
73
|
+
issues.push('no browser.toml files found under browser/base/content/test');
|
|
74
|
+
}
|
|
75
|
+
if (issues.length === 0) {
|
|
76
|
+
return ok('Post-rebase registration audit');
|
|
77
|
+
}
|
|
78
|
+
return warning('Post-rebase registration audit', `${issues.length} suspicious registration surface${issues.length === 1 ? '' : 's'}: ${issues.join('; ')}.`, 'Inspect the named engine paths, refresh any drifted registration patches, then re-run "fireforge doctor --post-rebase-audit".');
|
|
79
|
+
}
|
|
80
|
+
export const POST_REBASE_AUDIT_CHECK = {
|
|
81
|
+
name: 'Post-rebase registration audit',
|
|
82
|
+
skipIf: (ctx) => !ctx.options.postRebaseAudit || !ctx.engineExists,
|
|
83
|
+
dependsOn: ['fireforge.json is valid'],
|
|
84
|
+
run: runPostRebaseAudit,
|
|
85
|
+
};
|
|
86
|
+
//# sourceMappingURL=post-rebase-audit.js.map
|
|
@@ -10,6 +10,7 @@ import { toError } from '../utils/errors.js';
|
|
|
10
10
|
import { pathExists } from '../utils/fs.js';
|
|
11
11
|
import { error, info, intro, outro, success, warn } from '../utils/logger.js';
|
|
12
12
|
import { findExecutable } from '../utils/process.js';
|
|
13
|
+
import { POST_REBASE_AUDIT_CHECK } from './doctor/post-rebase-audit.js';
|
|
13
14
|
import { failure, ok, warning } from './doctor-check-core.js';
|
|
14
15
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
15
16
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
@@ -358,6 +359,7 @@ const DOCTOR_CHECKS = [
|
|
|
358
359
|
},
|
|
359
360
|
fix: 'Re-export affected files with "fireforge export <paths...>" to create full-file patches',
|
|
360
361
|
},
|
|
362
|
+
POST_REBASE_AUDIT_CHECK,
|
|
361
363
|
// Furnace checks live in a sibling module so this file stays under the
|
|
362
364
|
// max-lines threshold. Splicing them in as an array preserves the
|
|
363
365
|
// declarative registry contract — each entry remains a single
|
|
@@ -466,6 +468,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
|
|
|
466
468
|
.option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
|
|
467
469
|
.option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
|
|
468
470
|
.option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
|
|
471
|
+
.option('--post-rebase-audit', 'Check common registration surfaces after a Firefox source rebase')
|
|
469
472
|
.action(withErrorHandling(async (options) => {
|
|
470
473
|
const result = await doctorCommand(getProjectRoot(), options);
|
|
471
474
|
if (result.exitCode !== 0) {
|
|
@@ -249,7 +249,10 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
249
249
|
phaseState.value = 'extract';
|
|
250
250
|
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
251
251
|
}
|
|
252
|
-
}, config.firefox.sha256)
|
|
252
|
+
}, config.firefox.sha256, (message) => {
|
|
253
|
+
if (phaseState.value === 'extract')
|
|
254
|
+
s.message(message);
|
|
255
|
+
});
|
|
253
256
|
if (phaseState.value === 'extract') {
|
|
254
257
|
s.stop(`Firefox ${version} extracted`);
|
|
255
258
|
}
|
|
@@ -18,6 +18,7 @@ import { registerReset } from './reset.js';
|
|
|
18
18
|
import { registerResolve } from './resolve.js';
|
|
19
19
|
import { registerRun } from './run.js';
|
|
20
20
|
import { registerSetup } from './setup.js';
|
|
21
|
+
import { registerSource } from './source.js';
|
|
21
22
|
import { registerStatus } from './status.js';
|
|
22
23
|
import { registerTest } from './test.js';
|
|
23
24
|
import { registerToken } from './token.js';
|
|
@@ -31,6 +32,7 @@ import { registerWire } from './wire.js';
|
|
|
31
32
|
*/
|
|
32
33
|
export const COMMAND_MANIFEST = [
|
|
33
34
|
{ name: 'setup', group: 'project', register: registerSetup },
|
|
35
|
+
{ name: 'source', group: 'project', register: registerSource },
|
|
34
36
|
{ name: 'download', group: 'engine', register: registerDownload },
|
|
35
37
|
{ name: 'bootstrap', group: 'engine', register: registerBootstrap },
|
|
36
38
|
{ name: 'import', group: 'workflow', register: registerImport },
|
|
@@ -92,6 +92,41 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
92
92
|
}
|
|
93
93
|
return updates;
|
|
94
94
|
}
|
|
95
|
+
async function confirmFilesModeProjection(args) {
|
|
96
|
+
const { target, retained, removed, added, actualProjectedFiles, missingFiles, options, conflicts, } = args;
|
|
97
|
+
const isDryRun = options.dryRun === true;
|
|
98
|
+
const summary = [
|
|
99
|
+
`re-export ${target.filename} with --files scope`,
|
|
100
|
+
`current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
|
|
101
|
+
`retained files (${retained.length}): ${retained.join(', ') || '(none)'}`,
|
|
102
|
+
`projected files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
|
|
103
|
+
];
|
|
104
|
+
if (removed.length > 0) {
|
|
105
|
+
summary.push(`removed files (${removed.length}; become unmanaged): ${removed.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
if (added.length > 0) {
|
|
108
|
+
summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
if (missingFiles.length > 0) {
|
|
111
|
+
summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
|
|
114
|
+
warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
|
|
115
|
+
for (const line of summary) {
|
|
116
|
+
info(` ${line}`);
|
|
117
|
+
}
|
|
118
|
+
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output.`, '--allow-shrink');
|
|
119
|
+
}
|
|
120
|
+
return confirmDestructive({
|
|
121
|
+
operation: 're-export-files',
|
|
122
|
+
title: `Re-export ${target.filename} with --files`,
|
|
123
|
+
summary,
|
|
124
|
+
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
125
|
+
dryRun: isDryRun,
|
|
126
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
127
|
+
conflicts,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
95
130
|
/**
|
|
96
131
|
* Handles `re-export --files` end-to-end: computes the projected diff,
|
|
97
132
|
* runs the per-patch and cross-patch lint against a context in which the
|
|
@@ -106,7 +141,6 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
106
141
|
* of the projected state.
|
|
107
142
|
*/
|
|
108
143
|
export async function reExportFilesInPlace(paths, selectedPatches, options, config) {
|
|
109
|
-
const isDryRun = options.dryRun === true;
|
|
110
144
|
const target = selectedPatches[0];
|
|
111
145
|
if (!target) {
|
|
112
146
|
throw new InvalidArgumentError('--files requires a target patch.', '--files');
|
|
@@ -118,6 +152,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
118
152
|
const requested = [...new Set(filesOption)].sort();
|
|
119
153
|
const removed = target.filesAffected.filter((f) => !requested.includes(f));
|
|
120
154
|
const added = requested.filter((f) => !target.filesAffected.includes(f));
|
|
155
|
+
const retained = target.filesAffected.filter((f) => requested.includes(f));
|
|
121
156
|
// Filter out paths that no longer exist on disk; we cannot include
|
|
122
157
|
// them in the new diff because getDiffForFilesAgainstHead would fail.
|
|
123
158
|
// Missing files are still dropped from the manifest so the resulting
|
|
@@ -175,31 +210,14 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
175
210
|
forceUnsafe: options.forceUnsafe === true,
|
|
176
211
|
});
|
|
177
212
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
];
|
|
187
|
-
if (removed.length > 0) {
|
|
188
|
-
summary.push(`would drop (become unmanaged): ${removed.join(', ')}`);
|
|
189
|
-
}
|
|
190
|
-
if (added.length > 0) {
|
|
191
|
-
summary.push(`would add: ${added.join(', ')}`);
|
|
192
|
-
}
|
|
193
|
-
if (missingFiles.length > 0) {
|
|
194
|
-
summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
|
|
195
|
-
}
|
|
196
|
-
const decision = await confirmDestructive({
|
|
197
|
-
operation: 're-export-files',
|
|
198
|
-
title: `Re-export ${target.filename} with --files`,
|
|
199
|
-
summary,
|
|
200
|
-
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
201
|
-
dryRun: isDryRun,
|
|
202
|
-
unsafeOverride: options.forceUnsafe === true,
|
|
213
|
+
const decision = await confirmFilesModeProjection({
|
|
214
|
+
target,
|
|
215
|
+
retained,
|
|
216
|
+
removed,
|
|
217
|
+
added,
|
|
218
|
+
actualProjectedFiles,
|
|
219
|
+
missingFiles,
|
|
220
|
+
options,
|
|
203
221
|
conflicts,
|
|
204
222
|
});
|
|
205
223
|
if (decision === 'cancelled') {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { multiselect } from '@clack/prompts';
|
|
4
4
|
import { Option } from 'commander';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { isGitRepository } from '../core/git.js';
|
|
7
7
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
|
+
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
|
-
import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
10
|
+
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
10
11
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
11
12
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
13
|
+
import { elapsedSince } from '../utils/elapsed.js';
|
|
12
14
|
import { toError } from '../utils/errors.js';
|
|
13
15
|
import { pathExists } from '../utils/fs.js';
|
|
14
16
|
import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
|
|
@@ -24,6 +26,49 @@ async function findMissingFiles(engineDir, files) {
|
|
|
24
26
|
}
|
|
25
27
|
return missingFiles;
|
|
26
28
|
}
|
|
29
|
+
async function findLikelyNewSiblingFiles(args) {
|
|
30
|
+
const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
|
|
31
|
+
const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
|
|
32
|
+
const currentSet = new Set(currentFilesAffected);
|
|
33
|
+
const claimedByOthers = getClaimedFiles(manifest, patchFilename);
|
|
34
|
+
const candidates = new Set();
|
|
35
|
+
for (const dir of parentDirs) {
|
|
36
|
+
const [modifiedFiles, untrackedFiles] = await Promise.all([
|
|
37
|
+
getModifiedFilesInDir(engineDir, dir),
|
|
38
|
+
getUntrackedFilesInDir(engineDir, dir),
|
|
39
|
+
]);
|
|
40
|
+
for (const file of [...modifiedFiles, ...untrackedFiles]) {
|
|
41
|
+
if (currentSet.has(file) || claimedByOthers.has(file))
|
|
42
|
+
continue;
|
|
43
|
+
candidates.add(file);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...candidates].sort();
|
|
47
|
+
}
|
|
48
|
+
async function warnPlainReExportFileDrift(args) {
|
|
49
|
+
const { patch, paths, manifest, currentFilesAffected } = args;
|
|
50
|
+
const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
51
|
+
if (missingFiles.length > 0) {
|
|
52
|
+
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
53
|
+
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
54
|
+
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
55
|
+
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
56
|
+
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
57
|
+
`or pass --files <paths> to set the list explicitly.`);
|
|
58
|
+
}
|
|
59
|
+
const likelyNewFiles = await findLikelyNewSiblingFiles({
|
|
60
|
+
currentFilesAffected,
|
|
61
|
+
engineDir: paths.engine,
|
|
62
|
+
manifest,
|
|
63
|
+
patchFilename: patch.filename,
|
|
64
|
+
});
|
|
65
|
+
if (likelyNewFiles.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
|
|
68
|
+
for (const file of likelyNewFiles) {
|
|
69
|
+
info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
27
72
|
async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
|
|
28
73
|
let currentFilesAffected = [...patch.filesAffected];
|
|
29
74
|
// --- Scan for new/removed files ---
|
|
@@ -61,15 +106,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
61
106
|
// warning up-front when we can detect the drift cheaply, so the
|
|
62
107
|
// operator has a chance to re-run with `--scan` or `--files`
|
|
63
108
|
// before the stale filesAffected lands in patches.json.
|
|
64
|
-
|
|
65
|
-
if (missingFiles.length > 0) {
|
|
66
|
-
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
67
|
-
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
68
|
-
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
69
|
-
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
70
|
-
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
71
|
-
`or pass --files <paths> to set the list explicitly.`);
|
|
72
|
-
}
|
|
109
|
+
await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
|
|
73
110
|
}
|
|
74
111
|
// --- Explicit file-subset path ---
|
|
75
112
|
// When --files is given, the target filesAffected is authoritative — drop
|
|
@@ -305,8 +342,9 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
305
342
|
let reExported = 0;
|
|
306
343
|
const reExportedFilenames = [];
|
|
307
344
|
const progress = spinner('Preparing re-export...');
|
|
308
|
-
|
|
309
|
-
|
|
345
|
+
const startedAt = Date.now();
|
|
346
|
+
for (const [index, patch] of selectedPatches.entries()) {
|
|
347
|
+
progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
|
|
310
348
|
try {
|
|
311
349
|
const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
|
|
312
350
|
if (exported) {
|
|
@@ -370,7 +408,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
370
408
|
.filter((v) => v.length > 0))
|
|
371
409
|
.option('--dry-run', 'Show what would change without writing')
|
|
372
410
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
373
|
-
.option('-
|
|
411
|
+
.option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
|
|
412
|
+
.option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
|
|
374
413
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
375
414
|
.option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
|
|
376
415
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RebaseConflictSummary {
|
|
2
|
+
patchFilename: string;
|
|
3
|
+
failedFiles: string[];
|
|
4
|
+
category: string;
|
|
5
|
+
nextCommands: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Builds a concise operator-facing summary for a failed rebase patch. */
|
|
8
|
+
export declare function buildRebaseConflictSummary(args: {
|
|
9
|
+
patchFilename: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
rejectFiles?: string[];
|
|
12
|
+
}): RebaseConflictSummary;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { extractConflictingFiles } from '../../core/patch-parse.js';
|
|
3
|
+
function normalizeRejectFile(file) {
|
|
4
|
+
return file.replace(/\.rej$/, '');
|
|
5
|
+
}
|
|
6
|
+
function classifyConflict(files) {
|
|
7
|
+
if (files.some((file) => file.endsWith('toolkit/content/customElements.js'))) {
|
|
8
|
+
return 'registration context drift';
|
|
9
|
+
}
|
|
10
|
+
if (files.some((file) => file.endsWith('jar.mn') ||
|
|
11
|
+
file.endsWith('moz.build') ||
|
|
12
|
+
file.endsWith('browser.toml') ||
|
|
13
|
+
file.endsWith('browser/moz.configure'))) {
|
|
14
|
+
return 'manifest context drift';
|
|
15
|
+
}
|
|
16
|
+
return 'patch context drift';
|
|
17
|
+
}
|
|
18
|
+
/** Builds a concise operator-facing summary for a failed rebase patch. */
|
|
19
|
+
export function buildRebaseConflictSummary(args) {
|
|
20
|
+
const failedFiles = [
|
|
21
|
+
...new Set([
|
|
22
|
+
...extractConflictingFiles(args.error),
|
|
23
|
+
...(args.rejectFiles ?? []).map(normalizeRejectFile),
|
|
24
|
+
]),
|
|
25
|
+
].sort();
|
|
26
|
+
return {
|
|
27
|
+
patchFilename: args.patchFilename,
|
|
28
|
+
failedFiles,
|
|
29
|
+
category: classifyConflict(failedFiles),
|
|
30
|
+
nextCommands: [
|
|
31
|
+
"find engine -name '*.rej'",
|
|
32
|
+
'edit the affected engine/ files',
|
|
33
|
+
'fireforge rebase --continue',
|
|
34
|
+
'fireforge rebase --abort',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=conflict-summary.js.map
|
|
@@ -15,9 +15,11 @@ import { extractConflictingFiles } from '../../core/patch-parse.js';
|
|
|
15
15
|
import { clearRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
16
16
|
import { runInSignalCriticalSection } from '../../core/signal-critical.js';
|
|
17
17
|
import { RebaseError } from '../../errors/rebase.js';
|
|
18
|
+
import { elapsedSince } from '../../utils/elapsed.js';
|
|
18
19
|
import { toError } from '../../utils/errors.js';
|
|
19
20
|
import { pathExists } from '../../utils/fs.js';
|
|
20
21
|
import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
|
|
22
|
+
import { buildRebaseConflictSummary } from './conflict-summary.js';
|
|
21
23
|
import { printSummary } from './summary.js';
|
|
22
24
|
/**
|
|
23
25
|
* Runs the patch application loop, re-exports applied patches, and stamps versions.
|
|
@@ -37,7 +39,7 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
37
39
|
await saveRebaseSession(projectRoot, session);
|
|
38
40
|
continue;
|
|
39
41
|
}
|
|
40
|
-
s.message(`Applying ${entry.filename}...`);
|
|
42
|
+
s.message(`Applying ${i + 1}/${session.patches.length}: ${entry.filename}...`);
|
|
41
43
|
// Apply + session persist is wrapped in a signal-deferred critical
|
|
42
44
|
// section so a SIGINT / SIGTERM between the filesystem mutation and
|
|
43
45
|
// the session-file update is held until the bookkeeping write lands.
|
|
@@ -90,8 +92,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
90
92
|
},
|
|
91
93
|
}));
|
|
92
94
|
s.error(`${entry.filename} failed to apply`);
|
|
95
|
+
const summary = buildRebaseConflictSummary({
|
|
96
|
+
patchFilename: entry.filename,
|
|
97
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
98
|
+
...(result.rejectFiles !== undefined ? { rejectFiles: result.rejectFiles } : {}),
|
|
99
|
+
});
|
|
100
|
+
warn(`Conflict summary for ${summary.patchFilename}: ${summary.category}`);
|
|
101
|
+
if (summary.failedFiles.length > 0) {
|
|
102
|
+
warn(` Failed files: ${summary.failedFiles.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
warn(' Failed files: not detected from git output');
|
|
106
|
+
}
|
|
107
|
+
info(' Suggested next commands:');
|
|
108
|
+
for (const command of summary.nextCommands) {
|
|
109
|
+
info(` ${command}`);
|
|
110
|
+
}
|
|
93
111
|
if (result.error) {
|
|
94
|
-
error(`
|
|
112
|
+
error(` Raw apply detail: ${result.error}`);
|
|
95
113
|
}
|
|
96
114
|
if (result.rejectFiles && result.rejectFiles.length > 0) {
|
|
97
115
|
info(` .rej files created for manual resolution`);
|
|
@@ -167,19 +185,19 @@ async function reExportAppliedPatches(session, paths) {
|
|
|
167
185
|
if (!manifest)
|
|
168
186
|
return failures;
|
|
169
187
|
const s = spinner('Re-exporting patches...');
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
188
|
+
const reExportable = session.patches.filter((entry) => entry.status === 'applied-clean' || entry.status === 'applied-fuzz');
|
|
189
|
+
const startedAt = Date.now();
|
|
190
|
+
for (const [index, entry] of reExportable.entries()) {
|
|
173
191
|
const meta = manifest.patches.find((p) => p.filename === entry.filename);
|
|
174
192
|
if (!meta)
|
|
175
193
|
continue;
|
|
176
|
-
s.message(`Re-exporting ${entry.filename}...`);
|
|
177
194
|
const existingFiles = [];
|
|
178
195
|
for (const f of meta.filesAffected) {
|
|
179
196
|
if (await pathExists(join(paths.engine, f))) {
|
|
180
197
|
existingFiles.push(f);
|
|
181
198
|
}
|
|
182
199
|
}
|
|
200
|
+
s.message(`Re-exporting ${index + 1}/${reExportable.length}: ${entry.filename} (${existingFiles.length}/${meta.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
|
|
183
201
|
try {
|
|
184
202
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
185
203
|
if (diffContent.trim()) {
|
|
@@ -19,10 +19,13 @@ function renderLicenseTemplate(license, template, vendor, now = new Date()) {
|
|
|
19
19
|
return template.replace(/\[year\]/g, String(now.getFullYear())).replace(/\[fullname\]/g, vendor);
|
|
20
20
|
}
|
|
21
21
|
function resolveFirefoxProduct(value, field) {
|
|
22
|
-
if (value === 'firefox' ||
|
|
22
|
+
if (value === 'firefox' ||
|
|
23
|
+
value === 'firefox-esr' ||
|
|
24
|
+
value === 'firefox-beta' ||
|
|
25
|
+
value === 'firefox-devedition') {
|
|
23
26
|
return value;
|
|
24
27
|
}
|
|
25
|
-
throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta)', field);
|
|
28
|
+
throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta, firefox-devedition)', field);
|
|
26
29
|
}
|
|
27
30
|
function resolveProjectLicense(value, field) {
|
|
28
31
|
if (typeof value === 'string' && isValidProjectLicense(value)) {
|
|
@@ -153,6 +156,7 @@ async function promptSetupInputs(options) {
|
|
|
153
156
|
{ value: 'firefox', label: 'Firefox (stable releases)' },
|
|
154
157
|
{ value: 'firefox-esr', label: 'Firefox ESR (extended support)' },
|
|
155
158
|
{ value: 'firefox-beta', label: 'Firefox Beta (pre-release)' },
|
|
159
|
+
{ value: 'firefox-devedition', label: 'Firefox Developer Edition' },
|
|
156
160
|
],
|
|
157
161
|
});
|
|
158
162
|
},
|
|
@@ -71,6 +71,7 @@ export function registerSetup(program, { withErrorHandling }) {
|
|
|
71
71
|
'firefox',
|
|
72
72
|
'firefox-esr',
|
|
73
73
|
'firefox-beta',
|
|
74
|
+
'firefox-devedition',
|
|
74
75
|
]))
|
|
75
76
|
.addOption(new Option('--license <license>', 'Project license').choices([...PROJECT_LICENSES]))
|
|
76
77
|
.option('-f, --force', 'Overwrite existing configuration without prompting')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { CommandContext } from '../types/cli.js';
|
|
3
|
+
import type { SourceSetOptions } from '../types/commands/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Atomically updates the Firefox source tuple in fireforge.json.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sourceSetCommand(projectRoot: string, options: SourceSetOptions): Promise<void>;
|
|
8
|
+
/** Registers the source command on the CLI program. */
|
|
9
|
+
export declare function registerSource(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { Option } from 'commander';
|
|
3
|
+
import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock, writeConfigDocument, } from '../core/config.js';
|
|
4
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
5
|
+
import { info, intro, outro, success } from '../utils/logger.js';
|
|
6
|
+
import { isValidFirefoxProduct } from '../utils/validation.js';
|
|
7
|
+
const SOURCE_PRODUCTS = [
|
|
8
|
+
'firefox',
|
|
9
|
+
'firefox-esr',
|
|
10
|
+
'firefox-beta',
|
|
11
|
+
'firefox-devedition',
|
|
12
|
+
];
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function cloneRawConfig(raw) {
|
|
17
|
+
const cloned = structuredClone(raw);
|
|
18
|
+
if (!isRecord(cloned)) {
|
|
19
|
+
throw new GeneralError('Cannot update fireforge.json: config clone was not an object.');
|
|
20
|
+
}
|
|
21
|
+
return cloned;
|
|
22
|
+
}
|
|
23
|
+
function parseSourceProduct(product) {
|
|
24
|
+
if (isValidFirefoxProduct(product)) {
|
|
25
|
+
return product;
|
|
26
|
+
}
|
|
27
|
+
throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Atomically updates the Firefox source tuple in fireforge.json.
|
|
31
|
+
*/
|
|
32
|
+
export async function sourceSetCommand(projectRoot, options) {
|
|
33
|
+
intro('FireForge Source');
|
|
34
|
+
if (!(await configExists(projectRoot))) {
|
|
35
|
+
throw new GeneralError('No fireforge.json found. Run "fireforge setup" to create a project.');
|
|
36
|
+
}
|
|
37
|
+
if (options.sha256 !== undefined && options.clearSha256 === true) {
|
|
38
|
+
throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
|
|
39
|
+
}
|
|
40
|
+
const written = await withConfigFileLock(projectRoot, async () => {
|
|
41
|
+
const raw = await loadRawConfigDocument(projectRoot);
|
|
42
|
+
const updated = cloneRawConfig(raw);
|
|
43
|
+
const firefox = isRecord(updated['firefox']) ? { ...updated['firefox'] } : {};
|
|
44
|
+
firefox['version'] = options.version;
|
|
45
|
+
firefox['product'] = options.product;
|
|
46
|
+
if (options.clearSha256 === true) {
|
|
47
|
+
delete firefox['sha256'];
|
|
48
|
+
}
|
|
49
|
+
else if (options.sha256 !== undefined) {
|
|
50
|
+
firefox['sha256'] = options.sha256;
|
|
51
|
+
}
|
|
52
|
+
updated['firefox'] = firefox;
|
|
53
|
+
const validated = validateConfig(updated);
|
|
54
|
+
if (validated.firefox.sha256 !== undefined) {
|
|
55
|
+
firefox['sha256'] = validated.firefox.sha256;
|
|
56
|
+
}
|
|
57
|
+
await writeConfigDocument(projectRoot, updated);
|
|
58
|
+
return validated.firefox;
|
|
59
|
+
});
|
|
60
|
+
success(`Set firefox.version = ${written.version}`);
|
|
61
|
+
success(`Set firefox.product = ${written.product}`);
|
|
62
|
+
if (written.sha256 !== undefined) {
|
|
63
|
+
success(`Set firefox.sha256 = ${written.sha256}`);
|
|
64
|
+
}
|
|
65
|
+
else if (options.clearSha256 === true) {
|
|
66
|
+
info('Cleared firefox.sha256');
|
|
67
|
+
}
|
|
68
|
+
outro('');
|
|
69
|
+
}
|
|
70
|
+
/** Registers the source command on the CLI program. */
|
|
71
|
+
export function registerSource(program, { getProjectRoot, withErrorHandling }) {
|
|
72
|
+
const source = program.command('source').description('Manage Firefox source configuration');
|
|
73
|
+
source
|
|
74
|
+
.command('set')
|
|
75
|
+
.description('Atomically set Firefox source version, product, and optional checksum')
|
|
76
|
+
.requiredOption('--version <version>', 'Firefox version to base on')
|
|
77
|
+
.addOption(new Option('--product <product>', 'Firefox product')
|
|
78
|
+
.choices([...SOURCE_PRODUCTS])
|
|
79
|
+
.makeOptionMandatory())
|
|
80
|
+
.option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
|
|
81
|
+
.option('--clear-sha256', 'Clear any existing pinned SHA-256')
|
|
82
|
+
.action(withErrorHandling(async (options) => {
|
|
83
|
+
const { product, version, sha256, clearSha256 } = options;
|
|
84
|
+
await sourceSetCommand(getProjectRoot(), {
|
|
85
|
+
version,
|
|
86
|
+
product: parseSourceProduct(product),
|
|
87
|
+
...(sha256 !== undefined ? { sha256 } : {}),
|
|
88
|
+
...(clearSha256 !== undefined ? { clearSha256 } : {}),
|
|
89
|
+
});
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=source.js.map
|