@hominis/fireforge 0.27.1 → 0.27.2
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 +6 -0
- package/README.md +5 -5
- package/dist/src/cli.js +5 -1
- package/dist/src/commands/build.js +61 -1
- package/dist/src/commands/doctor-working-tree.js +5 -1
- package/dist/src/commands/download.js +41 -45
- package/dist/src/commands/export-all.js +3 -2
- package/dist/src/commands/export-flow.d.ts +2 -0
- package/dist/src/commands/export-flow.js +2 -0
- package/dist/src/commands/export.js +5 -4
- package/dist/src/commands/import.js +2 -1
- package/dist/src/commands/re-export.js +6 -6
- package/dist/src/commands/rebase/continue.js +2 -0
- package/dist/src/commands/rebase/index.d.ts +2 -2
- package/dist/src/commands/rebase/index.js +9 -4
- package/dist/src/commands/rebase/patch-loop.js +5 -5
- package/dist/src/commands/rebase/summary.js +7 -2
- package/dist/src/commands/resolve.js +2 -1
- package/dist/src/commands/status-output.d.ts +13 -0
- package/dist/src/commands/status-output.js +186 -0
- package/dist/src/commands/status.js +4 -247
- package/dist/src/commands/verify.js +32 -16
- package/dist/src/core/build-prepare.js +12 -4
- package/dist/src/core/firefox-cache.d.ts +1 -1
- package/dist/src/core/firefox-cache.js +10 -3
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/git.js +7 -2
- package/dist/src/core/ownership-table.d.ts +3 -1
- package/dist/src/core/ownership-table.js +31 -7
- package/dist/src/core/patch-export.d.ts +4 -0
- package/dist/src/core/patch-export.js +4 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
- package/dist/src/core/patch-manifest-consistency.js +4 -2
- package/dist/src/core/patch-manifest-query.d.ts +4 -3
- package/dist/src/core/patch-manifest-query.js +12 -4
- package/dist/src/core/patch-manifest-validate.js +22 -4
- package/dist/src/core/patch-source-metadata.d.ts +8 -0
- package/dist/src/core/patch-source-metadata.js +17 -0
- package/dist/src/core/rebase-session.d.ts +8 -3
- package/dist/src/core/rebase-session.js +1 -1
- package/dist/src/core/status-classify.d.ts +4 -1
- package/dist/src/core/status-classify.js +4 -5
- package/dist/src/types/commands/patches.d.ts +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
3
|
+
import { GeneralError } from '../errors/base.js';
|
|
4
|
+
import { info, outro, warn } from '../utils/logger.js';
|
|
5
|
+
const STATUS_DESCRIPTIONS = {
|
|
6
|
+
M: 'modified',
|
|
7
|
+
A: 'added',
|
|
8
|
+
D: 'deleted',
|
|
9
|
+
R: 'renamed',
|
|
10
|
+
C: 'copied',
|
|
11
|
+
U: 'unmerged',
|
|
12
|
+
'?': 'untracked',
|
|
13
|
+
'!': 'ignored',
|
|
14
|
+
};
|
|
15
|
+
function getStatusDescription(code) {
|
|
16
|
+
return STATUS_DESCRIPTIONS[code] ?? 'changed';
|
|
17
|
+
}
|
|
18
|
+
function getPrimaryStatusCode(status) {
|
|
19
|
+
if (status.includes('?'))
|
|
20
|
+
return '?';
|
|
21
|
+
if (status.includes('!'))
|
|
22
|
+
return '!';
|
|
23
|
+
for (const code of status) {
|
|
24
|
+
if (code !== ' ')
|
|
25
|
+
return code;
|
|
26
|
+
}
|
|
27
|
+
return status;
|
|
28
|
+
}
|
|
29
|
+
function isNewFileStatus(status) {
|
|
30
|
+
const code = getPrimaryStatusCode(status);
|
|
31
|
+
return code === '?' || code === 'A';
|
|
32
|
+
}
|
|
33
|
+
function groupFilesByStatus(files) {
|
|
34
|
+
const grouped = new Map();
|
|
35
|
+
for (const { status, file } of files) {
|
|
36
|
+
const code = getPrimaryStatusCode(status);
|
|
37
|
+
const existing = grouped.get(code) ?? [];
|
|
38
|
+
existing.push(file);
|
|
39
|
+
grouped.set(code, existing);
|
|
40
|
+
}
|
|
41
|
+
return grouped;
|
|
42
|
+
}
|
|
43
|
+
function printStatusGroups(files) {
|
|
44
|
+
const grouped = groupFilesByStatus(files);
|
|
45
|
+
for (const [status, fileList] of grouped) {
|
|
46
|
+
warn(`${getStatusDescription(status)}:`);
|
|
47
|
+
for (const file of fileList)
|
|
48
|
+
info(` ${file}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function printUnregisteredWarnings(files, projectRoot, binaryName) {
|
|
52
|
+
const newFiles = files.filter((f) => isNewFileStatus(f.status));
|
|
53
|
+
if (newFiles.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
|
|
56
|
+
const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
|
|
57
|
+
try {
|
|
58
|
+
return {
|
|
59
|
+
file: f.file,
|
|
60
|
+
registered: await isFileRegistered(projectRoot, f.file),
|
|
61
|
+
manifestMissing: false,
|
|
62
|
+
manifestMissingMessage: undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
|
|
67
|
+
return {
|
|
68
|
+
file: f.file,
|
|
69
|
+
registered: false,
|
|
70
|
+
manifestMissing: true,
|
|
71
|
+
manifestMissingMessage: err.message,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
|
|
78
|
+
const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
|
|
79
|
+
if (unregistered.length > 0) {
|
|
80
|
+
info('');
|
|
81
|
+
warn('Potentially unregistered files:');
|
|
82
|
+
for (const f of unregistered)
|
|
83
|
+
info(` ${f.file} — run 'fireforge register ${f.file}'`);
|
|
84
|
+
}
|
|
85
|
+
if (manifestMissing.length > 0) {
|
|
86
|
+
info('');
|
|
87
|
+
warn('Files whose registration manifest does not exist yet:');
|
|
88
|
+
for (const f of manifestMissing) {
|
|
89
|
+
info(` ${f.file} — ${f.manifestMissingMessage}`);
|
|
90
|
+
info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Renders the unmanaged-only status view and registration hints. */
|
|
95
|
+
export async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
|
|
96
|
+
info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
|
|
97
|
+
if (unmanagedFiles.length > 0) {
|
|
98
|
+
printStatusGroups(unmanagedFiles);
|
|
99
|
+
await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
info('No unmanaged changes');
|
|
103
|
+
}
|
|
104
|
+
outro(unmanagedFiles.length === 0
|
|
105
|
+
? 'No unmanaged changes'
|
|
106
|
+
: `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
|
|
107
|
+
}
|
|
108
|
+
/** Renders the default classified status buckets. */
|
|
109
|
+
export async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
|
|
110
|
+
const { conflict, unmanaged, patchBacked, patchOwnedDrift, branding, furnace } = buckets;
|
|
111
|
+
info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
|
|
112
|
+
if (conflict.length > 0) {
|
|
113
|
+
warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
|
|
114
|
+
printStatusGroups(conflict);
|
|
115
|
+
for (const entry of conflict) {
|
|
116
|
+
if (entry.claimedBy && entry.claimedBy.length > 0) {
|
|
117
|
+
info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
|
|
121
|
+
}
|
|
122
|
+
if (unmanaged.length > 0) {
|
|
123
|
+
if (conflict.length > 0)
|
|
124
|
+
info('');
|
|
125
|
+
warn('Unmanaged changes:');
|
|
126
|
+
printStatusGroups(unmanaged);
|
|
127
|
+
await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
|
|
128
|
+
}
|
|
129
|
+
if (patchBacked.length > 0) {
|
|
130
|
+
if (conflict.length > 0 || unmanaged.length > 0)
|
|
131
|
+
info('');
|
|
132
|
+
warn('Patch-backed materialized changes:');
|
|
133
|
+
printStatusGroups(patchBacked);
|
|
134
|
+
}
|
|
135
|
+
if (patchOwnedDrift.length > 0) {
|
|
136
|
+
if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0)
|
|
137
|
+
info('');
|
|
138
|
+
warn('Patch-owned drift:');
|
|
139
|
+
printStatusGroups(patchOwnedDrift);
|
|
140
|
+
info('These files are claimed by exactly one patch, but engine/ no longer matches that patch output. Re-export the owning patch after reviewing the manual resolution.');
|
|
141
|
+
}
|
|
142
|
+
if (branding.length > 0) {
|
|
143
|
+
if (conflict.length > 0 ||
|
|
144
|
+
unmanaged.length > 0 ||
|
|
145
|
+
patchBacked.length > 0 ||
|
|
146
|
+
patchOwnedDrift.length > 0) {
|
|
147
|
+
info('');
|
|
148
|
+
}
|
|
149
|
+
warn('Tool-managed branding changes:');
|
|
150
|
+
printStatusGroups(branding);
|
|
151
|
+
}
|
|
152
|
+
if (furnace.length > 0) {
|
|
153
|
+
if (conflict.length > 0 ||
|
|
154
|
+
unmanaged.length > 0 ||
|
|
155
|
+
patchBacked.length > 0 ||
|
|
156
|
+
patchOwnedDrift.length > 0 ||
|
|
157
|
+
branding.length > 0) {
|
|
158
|
+
info('');
|
|
159
|
+
}
|
|
160
|
+
warn('Furnace-managed component changes:');
|
|
161
|
+
printStatusGroups(furnace);
|
|
162
|
+
}
|
|
163
|
+
if (conflict.length === 0 &&
|
|
164
|
+
unmanaged.length === 0 &&
|
|
165
|
+
patchBacked.length === 0 &&
|
|
166
|
+
patchOwnedDrift.length === 0 &&
|
|
167
|
+
branding.length === 0 &&
|
|
168
|
+
furnace.length === 0) {
|
|
169
|
+
info('No changes');
|
|
170
|
+
}
|
|
171
|
+
const parts = [];
|
|
172
|
+
if (conflict.length > 0)
|
|
173
|
+
parts.push(`${conflict.length} conflict`);
|
|
174
|
+
if (unmanaged.length > 0)
|
|
175
|
+
parts.push(`${unmanaged.length} unmanaged`);
|
|
176
|
+
if (patchBacked.length > 0)
|
|
177
|
+
parts.push(`${patchBacked.length} patch-backed`);
|
|
178
|
+
if (patchOwnedDrift.length > 0)
|
|
179
|
+
parts.push(`${patchOwnedDrift.length} patch-owned drift`);
|
|
180
|
+
if (branding.length > 0)
|
|
181
|
+
parts.push(`${branding.length} branding`);
|
|
182
|
+
if (furnace.length > 0)
|
|
183
|
+
parts.push(`${furnace.length} furnace`);
|
|
184
|
+
outro(parts.join(', '));
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=status-output.js.map
|
|
@@ -2,7 +2,6 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
2
2
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
3
3
|
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
4
4
|
import { getUntrackedFilesInDir } from '../core/git-status.js';
|
|
5
|
-
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
6
5
|
import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
|
|
7
6
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
|
|
8
7
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
@@ -11,118 +10,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
|
|
|
11
10
|
import { ExitCode } from '../errors/codes.js';
|
|
12
11
|
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
13
12
|
import { info, intro, outro, warn } from '../utils/logger.js';
|
|
14
|
-
|
|
15
|
-
* Status code descriptions for git status.
|
|
16
|
-
*/
|
|
17
|
-
const STATUS_DESCRIPTIONS = {
|
|
18
|
-
M: 'modified',
|
|
19
|
-
A: 'added',
|
|
20
|
-
D: 'deleted',
|
|
21
|
-
R: 'renamed',
|
|
22
|
-
C: 'copied',
|
|
23
|
-
U: 'unmerged',
|
|
24
|
-
'?': 'untracked',
|
|
25
|
-
'!': 'ignored',
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* Gets a human-readable description for a git status code.
|
|
29
|
-
*/
|
|
30
|
-
function getStatusDescription(code) {
|
|
31
|
-
return STATUS_DESCRIPTIONS[code] ?? 'changed';
|
|
32
|
-
}
|
|
33
|
-
function getPrimaryStatusCode(status) {
|
|
34
|
-
if (status.includes('?'))
|
|
35
|
-
return '?';
|
|
36
|
-
if (status.includes('!'))
|
|
37
|
-
return '!';
|
|
38
|
-
for (const code of status) {
|
|
39
|
-
if (code !== ' ') {
|
|
40
|
-
return code;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return status;
|
|
44
|
-
}
|
|
45
|
-
function isNewFileStatus(status) {
|
|
46
|
-
const code = getPrimaryStatusCode(status);
|
|
47
|
-
return code === '?' || code === 'A';
|
|
48
|
-
}
|
|
49
|
-
function groupFilesByStatus(files) {
|
|
50
|
-
const grouped = new Map();
|
|
51
|
-
for (const { status, file } of files) {
|
|
52
|
-
const code = getPrimaryStatusCode(status);
|
|
53
|
-
const existing = grouped.get(code) ?? [];
|
|
54
|
-
existing.push(file);
|
|
55
|
-
grouped.set(code, existing);
|
|
56
|
-
}
|
|
57
|
-
return grouped;
|
|
58
|
-
}
|
|
59
|
-
function printStatusGroups(files) {
|
|
60
|
-
const grouped = groupFilesByStatus(files);
|
|
61
|
-
for (const [status, fileList] of grouped) {
|
|
62
|
-
const description = getStatusDescription(status);
|
|
63
|
-
warn(`${description}:`);
|
|
64
|
-
for (const file of fileList) {
|
|
65
|
-
info(` ${file}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
async function printUnregisteredWarnings(files, projectRoot, binaryName) {
|
|
70
|
-
const newFiles = files.filter((f) => isNewFileStatus(f.status));
|
|
71
|
-
if (newFiles.length === 0)
|
|
72
|
-
return;
|
|
73
|
-
const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
|
|
74
|
-
// `isFileRegistered` throws `GeneralError("Manifest not found: ...")` when a
|
|
75
|
-
// rule sees a file whose parent manifest does not yet exist on disk — e.g.
|
|
76
|
-
// a brand-new `browser/modules/<binary>/` directory with no `moz.build`.
|
|
77
|
-
// `status` is a read-only reporter; before 0.18.1 the rejected promise
|
|
78
|
-
// bubbled through `Promise.all` and exited status with code 1, breaking the
|
|
79
|
-
// "use status --unmanaged to discover new files before running register"
|
|
80
|
-
// workflow. We now bucket missing-manifest cases into a distinct warning
|
|
81
|
-
// list while still surfacing the same actionable signal. Other error
|
|
82
|
-
// shapes continue to propagate (permission denied, corrupt file, etc.) so
|
|
83
|
-
// we do not silently hide anything surprising.
|
|
84
|
-
const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
|
|
85
|
-
try {
|
|
86
|
-
return {
|
|
87
|
-
file: f.file,
|
|
88
|
-
registered: await isFileRegistered(projectRoot, f.file),
|
|
89
|
-
manifestMissing: false,
|
|
90
|
-
manifestMissingMessage: undefined,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
catch (err) {
|
|
94
|
-
if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
|
|
95
|
-
return {
|
|
96
|
-
file: f.file,
|
|
97
|
-
registered: false,
|
|
98
|
-
manifestMissing: true,
|
|
99
|
-
manifestMissingMessage: err.message,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
throw err;
|
|
103
|
-
}
|
|
104
|
-
}));
|
|
105
|
-
const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
|
|
106
|
-
const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
|
|
107
|
-
if (unregistered.length > 0) {
|
|
108
|
-
info('');
|
|
109
|
-
warn('Potentially unregistered files:');
|
|
110
|
-
for (const f of unregistered) {
|
|
111
|
-
info(` ${f.file} — run 'fireforge register ${f.file}'`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (manifestMissing.length > 0) {
|
|
115
|
-
info('');
|
|
116
|
-
warn('Files whose registration manifest does not exist yet:');
|
|
117
|
-
for (const f of manifestMissing) {
|
|
118
|
-
// `manifestMissingMessage` is always the specific
|
|
119
|
-
// "Manifest not found: <path>" string when manifestMissing is
|
|
120
|
-
// true (see the catch branch above that sets them together).
|
|
121
|
-
info(` ${f.file} — ${f.manifestMissingMessage}`);
|
|
122
|
-
info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
13
|
+
import { renderDefaultStatus, renderUnmanagedOnly, } from './status-output.js';
|
|
126
14
|
/**
|
|
127
15
|
* Renders raw worktree status as machine-parseable porcelain-style output.
|
|
128
16
|
* Each line is: STATUS<tab>FILE
|
|
@@ -132,19 +20,6 @@ function renderRawStatus(files) {
|
|
|
132
20
|
process.stdout.write(`${status.trim()}\t${file}\n`);
|
|
133
21
|
}
|
|
134
22
|
}
|
|
135
|
-
/**
|
|
136
|
-
* Default maximum number of files we will materialise from a single
|
|
137
|
-
* untracked directory. Pathological inputs (an accidental dump of build
|
|
138
|
-
* output, a symlink that resolves into a huge unrelated tree, etc.)
|
|
139
|
-
* should not be able to balloon `status` into multi-gigabyte memory or
|
|
140
|
-
* hang the CLI. Going over this cap surfaces a warning so the user knows
|
|
141
|
-
* the listing has been truncated, and it bounds the JSON / default
|
|
142
|
-
* rendering paths.
|
|
143
|
-
*
|
|
144
|
-
* Override via the `FIREFORGE_MAX_UNTRACKED_FILES` environment variable
|
|
145
|
-
* for monorepos or fixture-heavy projects with legitimately large
|
|
146
|
-
* untracked directories.
|
|
147
|
-
*/
|
|
148
23
|
const DEFAULT_MAX_UNTRACKED_FILES_PER_DIR = 5000;
|
|
149
24
|
function resolveMaxUntrackedFilesPerDir() {
|
|
150
25
|
const raw = process.env['FIREFORGE_MAX_UNTRACKED_FILES'];
|
|
@@ -158,14 +33,6 @@ function resolveMaxUntrackedFilesPerDir() {
|
|
|
158
33
|
return parsed;
|
|
159
34
|
}
|
|
160
35
|
const MAX_UNTRACKED_FILES_PER_DIR = resolveMaxUntrackedFilesPerDir();
|
|
161
|
-
/**
|
|
162
|
-
* Emits a prominent top-of-output warning when one or more untracked
|
|
163
|
-
* directories were truncated during expansion. Individual per-dir warnings
|
|
164
|
-
* already fired inside expandDirectoryEntries but are easily lost in
|
|
165
|
-
* scrollback for large status outputs; this banner summarises the total
|
|
166
|
-
* hidden count so the user doesn't miss that an export based on this
|
|
167
|
-
* status would be incomplete.
|
|
168
|
-
*/
|
|
169
36
|
function renderTruncationBanner(truncations) {
|
|
170
37
|
if (truncations.length === 0)
|
|
171
38
|
return;
|
|
@@ -227,10 +94,6 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
|
|
|
227
94
|
status: f.status.trim(),
|
|
228
95
|
classification: f.classification,
|
|
229
96
|
};
|
|
230
|
-
// `claimedBy` is an optional field present only on conflict
|
|
231
|
-
// entries, so non-conflict output stays byte-identical to the
|
|
232
|
-
// pre-0.16.0 shape (no unconditional schema change for the
|
|
233
|
-
// 99% of entries that are not cross-patch conflicts).
|
|
234
97
|
if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
|
|
235
98
|
entry.claimedBy = [...f.claimedBy];
|
|
236
99
|
}
|
|
@@ -238,6 +101,7 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
|
|
|
238
101
|
});
|
|
239
102
|
const byClassification = {
|
|
240
103
|
unmanaged: 0,
|
|
104
|
+
'patch-owned-drift': 0,
|
|
241
105
|
'patch-backed': 0,
|
|
242
106
|
branding: 0,
|
|
243
107
|
furnace: 0,
|
|
@@ -306,25 +170,6 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
306
170
|
}
|
|
307
171
|
const paths = getProjectPaths(projectRoot);
|
|
308
172
|
const config = await loadConfig(projectRoot);
|
|
309
|
-
// `--json` mode contracts to machine-parseable output on every code path,
|
|
310
|
-
// including failure modes. Before this guard, errors raised below
|
|
311
|
-
// ("Firefox source not found", "engine is not a git repository") flowed
|
|
312
|
-
// through the normal styled error renderer in `withErrorHandling`, so
|
|
313
|
-
// scripts piping `status --json | jq` broke precisely when the engine was
|
|
314
|
-
// missing. Surface a structured `{ "error": ..., "code": ... }` payload
|
|
315
|
-
// and exit non-zero via GeneralError so the exit code still reflects the
|
|
316
|
-
// failure but stdout remains valid JSON. The same guard runs for
|
|
317
|
-
// ownership mode below because that path also throws on missing engine.
|
|
318
|
-
// 2026-04-26 eval Finding 1: throw `CommandError` rather than
|
|
319
|
-
// `GeneralError` after the JSON line lands on stdout. `GeneralError`
|
|
320
|
-
// is a `FireForgeError`, so the `withErrorHandling` wrapper in cli.ts
|
|
321
|
-
// calls `logError(error.userMessage)` on it, which routes the styled
|
|
322
|
-
// human banner through clack to stdout — `status --json` therefore
|
|
323
|
-
// emitted both the JSON object AND the `■ Firefox source not found …`
|
|
324
|
-
// line on stdout, breaking every script that pipes the command into
|
|
325
|
-
// a JSON parser. `CommandError` is the bin-only sentinel that
|
|
326
|
-
// `withErrorHandling` does not log: bin/fireforge.ts catches it,
|
|
327
|
-
// exits with the carried code, and stdout stays a single JSON line.
|
|
328
173
|
const emitJsonError = (code, message) => {
|
|
329
174
|
process.stdout.write(JSON.stringify({ schemaVersion: 1, error: message, code }) + '\n');
|
|
330
175
|
throw new CommandError(ExitCode.GENERAL_ERROR);
|
|
@@ -357,7 +202,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
357
202
|
const newFileCreatorsByPath = (await pathExists(paths.patches))
|
|
358
203
|
? collectNewFileCreatorsByPath(await buildPatchQueueContext(paths.patches))
|
|
359
204
|
: new Map();
|
|
360
|
-
const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath);
|
|
205
|
+
const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath, new Map((await classifyFiles(rawFilesOwnership, paths.engine, paths.patches, config.binaryName, await collectFurnaceManagedPrefixes(projectRoot))).map((entry) => [entry.file, entry.classification])));
|
|
361
206
|
renderOwnershipTable(rows);
|
|
362
207
|
const conflictCount = rows.filter((r) => r.conflict).length;
|
|
363
208
|
const unmanagedCount = rows.filter((r) => r.unmanaged).length;
|
|
@@ -429,6 +274,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
429
274
|
const buckets = {
|
|
430
275
|
conflict: classified.filter((f) => f.classification === 'conflict'),
|
|
431
276
|
unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
|
|
277
|
+
patchOwnedDrift: classified.filter((f) => f.classification === 'patch-owned-drift'),
|
|
432
278
|
patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
|
|
433
279
|
branding: classified.filter((f) => f.classification === 'branding'),
|
|
434
280
|
furnace: classified.filter((f) => f.classification === 'furnace'),
|
|
@@ -440,95 +286,6 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
440
286
|
}
|
|
441
287
|
await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
|
|
442
288
|
}
|
|
443
|
-
async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
|
|
444
|
-
info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
|
|
445
|
-
if (unmanagedFiles.length > 0) {
|
|
446
|
-
printStatusGroups(unmanagedFiles);
|
|
447
|
-
await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
info('No unmanaged changes');
|
|
451
|
-
}
|
|
452
|
-
outro(unmanagedFiles.length === 0
|
|
453
|
-
? 'No unmanaged changes'
|
|
454
|
-
: `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
|
|
455
|
-
}
|
|
456
|
-
/**
|
|
457
|
-
* Renders the default five-bucket status display: conflicts first
|
|
458
|
-
* (they block export/import/rebase), then unmanaged, patch-backed,
|
|
459
|
-
* branding, and furnace-managed sections. Cross-bucket separators
|
|
460
|
-
* ensure the sections are visually distinct without trailing empty
|
|
461
|
-
* groups. Empty buckets are omitted — the very-empty case surfaces a
|
|
462
|
-
* single `No changes` line.
|
|
463
|
-
*/
|
|
464
|
-
async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
|
|
465
|
-
const { conflict, unmanaged, patchBacked, branding, furnace } = buckets;
|
|
466
|
-
info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
|
|
467
|
-
if (conflict.length > 0) {
|
|
468
|
-
// Surface cross-patch ownership conflicts at the top of the default
|
|
469
|
-
// output — they block export/import/rebase and want immediate
|
|
470
|
-
// attention. The `--ownership` view already renders the full table;
|
|
471
|
-
// here we just name the files and point the operator at the
|
|
472
|
-
// canonical recovery path.
|
|
473
|
-
warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
|
|
474
|
-
printStatusGroups(conflict);
|
|
475
|
-
for (const entry of conflict) {
|
|
476
|
-
if (entry.claimedBy && entry.claimedBy.length > 0) {
|
|
477
|
-
info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
|
|
481
|
-
}
|
|
482
|
-
if (unmanaged.length > 0) {
|
|
483
|
-
if (conflict.length > 0)
|
|
484
|
-
info('');
|
|
485
|
-
warn('Unmanaged changes:');
|
|
486
|
-
printStatusGroups(unmanaged);
|
|
487
|
-
await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
|
|
488
|
-
}
|
|
489
|
-
if (patchBacked.length > 0) {
|
|
490
|
-
if (conflict.length > 0 || unmanaged.length > 0)
|
|
491
|
-
info('');
|
|
492
|
-
warn('Patch-backed materialized changes:');
|
|
493
|
-
printStatusGroups(patchBacked);
|
|
494
|
-
}
|
|
495
|
-
if (branding.length > 0) {
|
|
496
|
-
if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0) {
|
|
497
|
-
info('');
|
|
498
|
-
}
|
|
499
|
-
warn('Tool-managed branding changes:');
|
|
500
|
-
printStatusGroups(branding);
|
|
501
|
-
}
|
|
502
|
-
if (furnace.length > 0) {
|
|
503
|
-
if (conflict.length > 0 ||
|
|
504
|
-
unmanaged.length > 0 ||
|
|
505
|
-
patchBacked.length > 0 ||
|
|
506
|
-
branding.length > 0) {
|
|
507
|
-
info('');
|
|
508
|
-
}
|
|
509
|
-
warn('Furnace-managed component changes:');
|
|
510
|
-
printStatusGroups(furnace);
|
|
511
|
-
}
|
|
512
|
-
if (conflict.length === 0 &&
|
|
513
|
-
unmanaged.length === 0 &&
|
|
514
|
-
patchBacked.length === 0 &&
|
|
515
|
-
branding.length === 0 &&
|
|
516
|
-
furnace.length === 0) {
|
|
517
|
-
info('No changes');
|
|
518
|
-
}
|
|
519
|
-
const parts = [];
|
|
520
|
-
if (conflict.length > 0)
|
|
521
|
-
parts.push(`${conflict.length} conflict`);
|
|
522
|
-
if (unmanaged.length > 0)
|
|
523
|
-
parts.push(`${unmanaged.length} unmanaged`);
|
|
524
|
-
if (patchBacked.length > 0)
|
|
525
|
-
parts.push(`${patchBacked.length} patch-backed`);
|
|
526
|
-
if (branding.length > 0)
|
|
527
|
-
parts.push(`${branding.length} branding`);
|
|
528
|
-
if (furnace.length > 0)
|
|
529
|
-
parts.push(`${furnace.length} furnace`);
|
|
530
|
-
outro(parts.join(', '));
|
|
531
|
-
}
|
|
532
289
|
/** Registers the status command on the CLI program. */
|
|
533
290
|
export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
|
|
534
291
|
program
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
19
|
+
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
19
20
|
import { isGitRepository } from '../core/git.js';
|
|
20
21
|
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
21
22
|
import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
|
|
22
23
|
import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
|
|
23
24
|
import { evaluatePatchPolicy } from '../core/patch-policy.js';
|
|
24
25
|
import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
|
|
26
|
+
import { classifyFiles } from '../core/status-classify.js';
|
|
25
27
|
import { GeneralError } from '../errors/base.js';
|
|
26
28
|
import { pathExists, readText } from '../utils/fs.js';
|
|
27
29
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
@@ -102,14 +104,25 @@ function detectCrossPatchFileClaims(manifestPatches) {
|
|
|
102
104
|
}
|
|
103
105
|
return results;
|
|
104
106
|
}
|
|
105
|
-
async function
|
|
107
|
+
async function detectWorktreeOwnershipDrift(projectRoot, engineDir, patchesDir, binaryName) {
|
|
106
108
|
if (!(await pathExists(engineDir)) || !(await isGitRepository(engineDir))) {
|
|
107
|
-
return [];
|
|
109
|
+
return { unowned: [], patchOwnedDrift: [] };
|
|
108
110
|
}
|
|
109
111
|
const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
113
|
+
const classified = await classifyFiles(entries, engineDir, patchesDir, binaryName, furnacePrefixes);
|
|
114
|
+
return {
|
|
115
|
+
unowned: [
|
|
116
|
+
...new Set(classified
|
|
117
|
+
.filter((entry) => entry.classification === 'unmanaged')
|
|
118
|
+
.map((entry) => entry.file)),
|
|
119
|
+
].sort(),
|
|
120
|
+
patchOwnedDrift: [
|
|
121
|
+
...new Set(classified
|
|
122
|
+
.filter((entry) => entry.classification === 'patch-owned-drift')
|
|
123
|
+
.map((entry) => entry.file)),
|
|
124
|
+
].sort(),
|
|
125
|
+
};
|
|
113
126
|
}
|
|
114
127
|
/**
|
|
115
128
|
* Collects the same queue-health findings reported by `fireforge verify`
|
|
@@ -188,21 +201,24 @@ export async function collectPatchQueueHealth(projectRoot) {
|
|
|
188
201
|
warningCount += lintWarnings;
|
|
189
202
|
}
|
|
190
203
|
if (manifest) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
204
|
+
const worktreeDrift = await detectWorktreeOwnershipDrift(projectRoot, paths.engine, paths.patches, config.binaryName);
|
|
205
|
+
if (worktreeDrift.unowned.length > 0) {
|
|
206
|
+
groups.push({
|
|
207
|
+
title: `Unowned worktree changes (${worktreeDrift.unowned.length})`,
|
|
208
|
+
issues: worktreeDrift.unowned.map((file) => `${file} is changed in engine/ but is not listed in any patch filesAffected entry`),
|
|
209
|
+
errorCount: 0,
|
|
210
|
+
warningCount: worktreeDrift.unowned.length,
|
|
211
|
+
});
|
|
212
|
+
warningCount += worktreeDrift.unowned.length;
|
|
196
213
|
}
|
|
197
|
-
|
|
198
|
-
if (unownedWorktreeChanges.length > 0) {
|
|
214
|
+
if (worktreeDrift.patchOwnedDrift.length > 0) {
|
|
199
215
|
groups.push({
|
|
200
|
-
title: `
|
|
201
|
-
issues:
|
|
216
|
+
title: `Patch-owned worktree drift (${worktreeDrift.patchOwnedDrift.length})`,
|
|
217
|
+
issues: worktreeDrift.patchOwnedDrift.map((file) => `${file} is claimed by exactly one patch, but engine/ no longer matches that patch output`),
|
|
202
218
|
errorCount: 0,
|
|
203
|
-
warningCount:
|
|
219
|
+
warningCount: worktreeDrift.patchOwnedDrift.length,
|
|
204
220
|
});
|
|
205
|
-
warningCount +=
|
|
221
|
+
warningCount += worktreeDrift.patchOwnedDrift.length;
|
|
206
222
|
}
|
|
207
223
|
const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
|
|
208
224
|
if (registrationIssues.length > 0) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Shared pre-flight logic for build and package commands:
|
|
4
4
|
* story cleanup, branding setup, Furnace component application, and mozconfig generation.
|
|
5
5
|
*/
|
|
6
|
+
import { BuildError } from '../errors/build.js';
|
|
6
7
|
import { FurnaceError } from '../errors/furnace.js';
|
|
7
8
|
import { toError } from '../utils/errors.js';
|
|
8
9
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -103,21 +104,28 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
103
104
|
const changed = await collectBackendRelevantChanges(paths.engine, options.previousBaseline);
|
|
104
105
|
const invalidating = changed.filter(isBackendInvalidatingFile);
|
|
105
106
|
if (invalidating.length > 0) {
|
|
106
|
-
info(`Backend config changed; running
|
|
107
|
+
info(`Backend config changed; running backend regeneration first (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched).`);
|
|
108
|
+
info(`Backend command: mach configure`);
|
|
107
109
|
const configureSpinner = spinner('Running mach configure...');
|
|
108
110
|
try {
|
|
109
111
|
const exitCode = await runMach(['configure'], paths.engine);
|
|
110
112
|
if (exitCode !== 0) {
|
|
111
|
-
configureSpinner.error(
|
|
113
|
+
configureSpinner.error(`mach configure failed with exit code ${exitCode}`);
|
|
114
|
+
throw new BuildError(`Backend regeneration failed: mach configure exited with code ${exitCode}. Build stopped because continuing would hide the real configure failure.`, 'mach configure');
|
|
112
115
|
}
|
|
113
116
|
else {
|
|
114
|
-
configureSpinner.stop('Backend regenerated');
|
|
117
|
+
configureSpinner.stop('Backend regenerated successfully (mach configure exit code 0)');
|
|
118
|
+
info('Backend regeneration succeeded; continuing with build.');
|
|
115
119
|
reconfigured = true;
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
catch (error) {
|
|
119
|
-
|
|
123
|
+
if (error instanceof BuildError) {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
configureSpinner.error('mach configure failed');
|
|
120
127
|
verbose(`Auto-configure error: ${toError(error).message}`);
|
|
128
|
+
throw new BuildError(`Backend regeneration failed while running mach configure: ${toError(error).message}. Build stopped because continuing would hide the real configure failure.`, 'mach configure', error instanceof Error ? error : undefined);
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
}
|
|
@@ -14,7 +14,7 @@ export declare function sha256File(filePath: string): Promise<string>;
|
|
|
14
14
|
* @param cacheDir - Cache directory
|
|
15
15
|
* @param onProgress - Optional progress callback
|
|
16
16
|
*/
|
|
17
|
-
export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback, expectedSha256?: string): Promise<void>;
|
|
17
|
+
export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback, expectedSha256?: string, onCacheProgress?: (message: string) => void): Promise<void>;
|
|
18
18
|
/**
|
|
19
19
|
* Removes cached tarball, metadata, and partial download files for an archive.
|
|
20
20
|
* @param archive - Resolved archive descriptor
|