@hominis/fireforge 0.13.2 → 0.14.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.
- package/CHANGELOG.md +54 -0
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +13 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
|
-
import { getProjectPaths, loadConfig, loadState,
|
|
4
|
+
import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
|
|
5
5
|
import { getHead } from '../core/git.js';
|
|
6
6
|
import { getDirtyFiles } from '../core/git-status.js';
|
|
7
7
|
import { applyPatchesWithContinue, computePatchedContent, countPatches, discoverPatches, extractAffectedFiles, PatchError, } from '../core/patch-apply.js';
|
|
@@ -11,6 +11,19 @@ import { toError } from '../utils/errors.js';
|
|
|
11
11
|
import { pathExists, readText } from '../utils/fs.js';
|
|
12
12
|
import { error, info, intro, isCancel, outro, spinner, success, verbose, warn, } from '../utils/logger.js';
|
|
13
13
|
import { pickDefined } from '../utils/options.js';
|
|
14
|
+
/**
|
|
15
|
+
* Errno codes for filesystem-level failures against the working file.
|
|
16
|
+
* These are safe to fall through as "unmanaged" because they describe the
|
|
17
|
+
* *state of the engine directory*, not the integrity of the patch stack.
|
|
18
|
+
* Manifest / patch-parse / PatchError failures do NOT match this set and
|
|
19
|
+
* are re-thrown so the root cause surfaces instead of being silently
|
|
20
|
+
* reclassified as a spurious dirty file.
|
|
21
|
+
*/
|
|
22
|
+
const SAFE_IO_FALLBACK_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'EBUSY']);
|
|
23
|
+
function isSafeIoFallback(error) {
|
|
24
|
+
const code = error?.code;
|
|
25
|
+
return typeof code === 'string' && SAFE_IO_FALLBACK_CODES.has(code);
|
|
26
|
+
}
|
|
14
27
|
async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
|
|
15
28
|
const classifications = await Promise.all(dirtyFiles.map(async (file) => {
|
|
16
29
|
try {
|
|
@@ -22,7 +35,19 @@ async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
|
|
|
22
35
|
return actual === expected ? null : file;
|
|
23
36
|
}
|
|
24
37
|
catch (error) {
|
|
25
|
-
|
|
38
|
+
// PatchError, manifest corruption, and patch-parse failures are
|
|
39
|
+
// *structural* problems with the patch stack — masking them as
|
|
40
|
+
// "unmanaged dirty file" would let the user `--force` past a real
|
|
41
|
+
// root cause (e.g. "patch 003 missing from manifest") and compound
|
|
42
|
+
// the corruption. Only swallow the pure-IO fallback cases where
|
|
43
|
+
// the working file itself can't be read.
|
|
44
|
+
if (error instanceof PatchError) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
if (!isSafeIoFallback(error)) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
verbose(`Treating ${file} as unmanaged because patched-content classification failed with IO error: ${toError(error).message}`);
|
|
26
51
|
return file;
|
|
27
52
|
}
|
|
28
53
|
}));
|
|
@@ -64,14 +89,22 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
|
|
|
64
89
|
}
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
|
-
async function handlePatchFailures(summary,
|
|
92
|
+
async function handlePatchFailures(summary, projectRoot) {
|
|
68
93
|
const firstFailed = summary.failed[0];
|
|
69
94
|
if (firstFailed) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
// Transactional update rather than `loadState` + mutate + `saveState`. The
|
|
96
|
+
// caller captures `state` at the start of the import run, and the run can
|
|
97
|
+
// span a long window (drift-check prompt, patch apply loop). A concurrent
|
|
98
|
+
// command (`fireforge download`, `rebase`, another state mutation) writing
|
|
99
|
+
// unrelated fields during that window would be silently clobbered when the
|
|
100
|
+
// stale state object was written back.
|
|
101
|
+
await updateState(projectRoot, (current) => ({
|
|
102
|
+
...current,
|
|
103
|
+
pendingResolution: {
|
|
104
|
+
patchFilename: firstFailed.patch.filename,
|
|
105
|
+
originalError: firstFailed.error ?? 'Unknown error',
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
75
108
|
}
|
|
76
109
|
for (const result of summary.failed) {
|
|
77
110
|
error(`\nFailed: ${result.patch.filename}`);
|
|
@@ -187,14 +220,35 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
187
220
|
}
|
|
188
221
|
}
|
|
189
222
|
}
|
|
190
|
-
// Validate patch integrity (detect orphaned modification patches)
|
|
223
|
+
// Validate patch integrity (detect orphaned modification patches). Warn
|
|
224
|
+
// and prompt the operator to confirm before proceeding — the legacy
|
|
225
|
+
// warn-and-continue behaviour hid the real root cause because import
|
|
226
|
+
// would later fail during patch application with a secondary, unrelated
|
|
227
|
+
// error that made diagnosis harder.
|
|
191
228
|
const integrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
|
|
192
229
|
if (integrityIssues.length > 0) {
|
|
193
230
|
warn('\nPatch integrity issues detected:');
|
|
194
231
|
for (const issue of integrityIssues) {
|
|
195
232
|
warn(` ${issue.filename}: ${issue.message}`);
|
|
196
233
|
}
|
|
197
|
-
info('Run "fireforge doctor" for more details
|
|
234
|
+
info('Run "fireforge doctor" for more details.');
|
|
235
|
+
if (forceImport) {
|
|
236
|
+
warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
|
|
237
|
+
}
|
|
238
|
+
else if (!process.stdin.isTTY) {
|
|
239
|
+
throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
|
|
240
|
+
`Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const shouldContinue = await confirm({
|
|
244
|
+
message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
|
|
245
|
+
initialValue: false,
|
|
246
|
+
});
|
|
247
|
+
if (isCancel(shouldContinue) || !shouldContinue) {
|
|
248
|
+
outro('Import cancelled — fix the integrity issues and re-run');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
198
252
|
}
|
|
199
253
|
// Dry-run: list patches that would be applied and exit
|
|
200
254
|
if (isDryRun) {
|
|
@@ -226,7 +280,7 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
226
280
|
// Handle failures
|
|
227
281
|
if (summary.failed.length > 0) {
|
|
228
282
|
s.error(`${summary.failed.length} patch(es) failed`);
|
|
229
|
-
await handlePatchFailures(summary,
|
|
283
|
+
await handlePatchFailures(summary, projectRoot);
|
|
230
284
|
}
|
|
231
285
|
// Count auto-resolved patches
|
|
232
286
|
const autoResolved = summary.succeeded.filter((r) => r.autoResolved);
|
|
@@ -5,7 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
5
|
import { isGitRepository } from '../core/git.js';
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
7
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
|
-
import {
|
|
8
|
+
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
9
|
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -102,11 +102,18 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
102
102
|
info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
|
|
103
103
|
}
|
|
104
104
|
else {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Atomic body + manifest update under a single patch-directory lock.
|
|
106
|
+
// A split `updatePatch` (lock-free) + `updatePatchMetadata` (lock-guarded)
|
|
107
|
+
// sequence allows a concurrent `resolve` / `rebase --continue` / `patch
|
|
108
|
+
// compact` / `patch reorder` to rewrite the manifest between the two
|
|
109
|
+
// writes and leave patch body and `filesAffected` disagreeing.
|
|
110
|
+
await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, {
|
|
108
111
|
filesAffected: currentFilesAffected,
|
|
109
112
|
});
|
|
113
|
+
// Keep the in-memory manifest in sync so subsequent iterations (notably
|
|
114
|
+
// `--all --scan`, where `getClaimedFiles` reads from this manifest) see
|
|
115
|
+
// the just-written `filesAffected`. The on-disk write above is the
|
|
116
|
+
// authority; this is a cache update.
|
|
110
117
|
const patchIndex = manifest.patches.findIndex((pm) => pm.filename === patch.filename);
|
|
111
118
|
if (patchIndex !== -1) {
|
|
112
119
|
const existingEntry = manifest.patches[patchIndex];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Rebase abort flow.
|
|
4
4
|
*/
|
|
5
|
-
import { getProjectPaths,
|
|
5
|
+
import { getProjectPaths, updateState } from '../../core/config.js';
|
|
6
6
|
import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
|
|
7
7
|
import { resetChanges } from '../../core/git.js';
|
|
8
8
|
import { clearRebaseSession, loadRebaseSession } from '../../core/rebase-session.js';
|
|
@@ -22,7 +22,8 @@ export async function handleAbort(projectRoot, yes) {
|
|
|
22
22
|
if (!(await confirmDirtyEngineReset({
|
|
23
23
|
engineDir: paths.engine,
|
|
24
24
|
yes: yes ?? false,
|
|
25
|
-
|
|
25
|
+
nonInteractiveCommand: 'fireforge rebase --abort --yes',
|
|
26
|
+
argumentName: '--yes',
|
|
26
27
|
warningMessage: 'The engine directory has uncommitted changes that will be lost.',
|
|
27
28
|
promptMessage: 'Discard uncommitted changes and abort rebase?',
|
|
28
29
|
cancelMessage: 'Abort cancelled',
|
|
@@ -30,27 +31,38 @@ export async function handleAbort(projectRoot, yes) {
|
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
33
|
const s = spinner('Restoring engine to pre-rebase state...');
|
|
34
|
+
// Step 1: git reset. If this fails, the rebase session MUST stay on disk
|
|
35
|
+
// so the user can retry the abort — resetChanges is the only irreversible
|
|
36
|
+
// operation in this handler and everything downstream assumes it ran.
|
|
33
37
|
try {
|
|
34
38
|
await resetChanges(paths.engine);
|
|
35
39
|
s.stop('Engine restored');
|
|
36
|
-
// Clear Furnace state — the engine has been rolled back to pre-rebase state.
|
|
37
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
38
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
39
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
40
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
41
|
-
}));
|
|
42
|
-
}
|
|
43
40
|
}
|
|
44
41
|
catch (error) {
|
|
45
42
|
s.error('Failed to restore engine');
|
|
46
43
|
throw error;
|
|
47
44
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
// Step 2: clear Furnace state. A failure here is reported on its own
|
|
46
|
+
// (rather than labelled "Failed to restore engine", which would be
|
|
47
|
+
// misleading now that reset already succeeded). The rebase session is
|
|
48
|
+
// still kept on disk so the user can retry the abort and let it
|
|
49
|
+
// idempotently re-clear furnace state.
|
|
50
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
51
|
+
if (await pathExists(furnacePaths.furnaceState)) {
|
|
52
|
+
await updateFurnaceState(projectRoot, (current) => ({
|
|
53
|
+
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
54
|
+
}));
|
|
53
55
|
}
|
|
56
|
+
// Step 3: clear pending resolution transactionally.
|
|
57
|
+
await updateState(projectRoot, (current) => {
|
|
58
|
+
if (!current.pendingResolution)
|
|
59
|
+
return current;
|
|
60
|
+
const next = { ...current };
|
|
61
|
+
delete next.pendingResolution;
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
// Step 4: clear the rebase session LAST so a failure in any prior step
|
|
65
|
+
// preserves the session on disk and a retry of --abort can succeed.
|
|
54
66
|
await clearRebaseSession(projectRoot);
|
|
55
67
|
success('Rebase aborted and session cleared.');
|
|
56
68
|
outro('Rebase aborted');
|
|
@@ -5,7 +5,20 @@
|
|
|
5
5
|
export interface DirtyEngineConfirmationOptions {
|
|
6
6
|
engineDir: string;
|
|
7
7
|
yes: boolean;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Full remediation command the user should run in non-interactive mode,
|
|
10
|
+
* e.g. `"fireforge rebase --abort --yes"`. Rendered inline in the
|
|
11
|
+
* non-interactive error message so the user gets a paste-ready
|
|
12
|
+
* command instead of a bare flag name.
|
|
13
|
+
*/
|
|
14
|
+
nonInteractiveCommand: string;
|
|
15
|
+
/**
|
|
16
|
+
* Argument identifier attached to the thrown {@link InvalidArgumentError}
|
|
17
|
+
* (typically the flag name, e.g. `"--yes"`). Separate from
|
|
18
|
+
* `nonInteractiveCommand` so the error's `argument` field carries the
|
|
19
|
+
* canonical flag name for structured handling.
|
|
20
|
+
*/
|
|
21
|
+
argumentName: string;
|
|
9
22
|
warningMessage: string;
|
|
10
23
|
promptMessage: string;
|
|
11
24
|
cancelMessage: string;
|
|
@@ -15,4 +28,4 @@ export interface DirtyEngineConfirmationOptions {
|
|
|
15
28
|
* Returns true if safe to proceed, false if the user cancelled.
|
|
16
29
|
* Throws in non-interactive mode without --yes.
|
|
17
30
|
*/
|
|
18
|
-
export declare function confirmDirtyEngineReset({ engineDir, yes,
|
|
31
|
+
export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
|
|
@@ -11,13 +11,13 @@ import { cancel, isCancel, warn } from '../../utils/logger.js';
|
|
|
11
11
|
* Returns true if safe to proceed, false if the user cancelled.
|
|
12
12
|
* Throws in non-interactive mode without --yes.
|
|
13
13
|
*/
|
|
14
|
-
export async function confirmDirtyEngineReset({ engineDir, yes,
|
|
14
|
+
export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }) {
|
|
15
15
|
if (!(await hasChanges(engineDir)) || yes) {
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
19
19
|
if (!isInteractive) {
|
|
20
|
-
throw new InvalidArgumentError(
|
|
20
|
+
throw new InvalidArgumentError(`Engine has uncommitted changes and interactive confirmation is not available. Run: ${nonInteractiveCommand}`, argumentName);
|
|
21
21
|
}
|
|
22
22
|
warn(warningMessage);
|
|
23
23
|
const confirmed = await confirm({
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* Rebase --continue flow.
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { getProjectPaths,
|
|
6
|
+
import { getProjectPaths, updateState } from '../../core/config.js';
|
|
7
7
|
import { getStagedDiffForFiles } from '../../core/git-diff.js';
|
|
8
8
|
import { stageFiles, unstageFiles } from '../../core/git-file-ops.js';
|
|
9
|
-
import {
|
|
9
|
+
import { updatePatchAndMetadata } from '../../core/patch-export.js';
|
|
10
10
|
import { loadPatchesManifest } from '../../core/patch-manifest.js';
|
|
11
11
|
import { loadRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
12
|
+
import { runInSignalCriticalSection } from '../../core/signal-critical.js';
|
|
12
13
|
import { GeneralError } from '../../errors/base.js';
|
|
13
14
|
import { NoRebaseSessionError, RebaseError } from '../../errors/rebase.js';
|
|
14
15
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -23,6 +24,17 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
23
24
|
if (!session)
|
|
24
25
|
throw new NoRebaseSessionError();
|
|
25
26
|
const paths = getProjectPaths(projectRoot);
|
|
27
|
+
// Special case: every patch has already applied but a previous run failed
|
|
28
|
+
// somewhere in the post-apply work (re-export, version stamping). In that
|
|
29
|
+
// state currentIndex is past the end of the queue; jumping straight back
|
|
30
|
+
// into runPatchLoop replays the no-op apply loop and re-attempts the
|
|
31
|
+
// post-apply pipeline. Without this branch the user would be stuck —
|
|
32
|
+
// there is no failed patch to resolve, but the session is still active.
|
|
33
|
+
if (session.currentIndex >= session.patches.length) {
|
|
34
|
+
info('All patches already applied; retrying post-apply re-export and version stamping.');
|
|
35
|
+
await runPatchLoop(projectRoot, session, paths, maxFuzz);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
26
38
|
// The current patch should be in 'failed' state
|
|
27
39
|
const currentPatch = session.patches[session.currentIndex];
|
|
28
40
|
if (!currentPatch || currentPatch.status !== 'failed') {
|
|
@@ -53,9 +65,13 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
53
65
|
warn('Either apply your fixes and re-run --continue, or skip this patch (not yet supported).');
|
|
54
66
|
return;
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
// Write the patch body and the manifest metadata atomically under the
|
|
69
|
+
// shared patch-directory lock. The previous lock-free sequence of
|
|
70
|
+
// updatePatch + updatePatchMetadata could interleave with a concurrent
|
|
71
|
+
// export / re-export / patch reorder / patch compact and leave the
|
|
72
|
+
// manifest disagreeing with the freshly-written patch body. Mirrors the
|
|
73
|
+
// v0.14.0 resolve.ts fix.
|
|
74
|
+
await updatePatchAndMetadata(paths.patches, currentPatch.filename, diffContent, {
|
|
59
75
|
sourceEsrVersion: session.toVersion,
|
|
60
76
|
});
|
|
61
77
|
}
|
|
@@ -64,16 +80,24 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
64
80
|
await unstageFiles(paths.engine, activeFiles);
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
|
-
// Mark resolved and advance
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
// Mark resolved and advance. Wrap in a signal-deferred critical section
|
|
84
|
+
// so SIGINT / SIGTERM between the session update and the pendingResolution
|
|
85
|
+
// clear is held until both writes land, matching the guarantee the apply
|
|
86
|
+
// loop in patch-loop.ts provides.
|
|
87
|
+
await runInSignalCriticalSection(`rebase-continue:${currentPatch.filename}`, async () => {
|
|
88
|
+
currentPatch.status = 'resolved';
|
|
89
|
+
session.currentIndex++;
|
|
90
|
+
await saveRebaseSession(projectRoot, session);
|
|
91
|
+
// Clear pending resolution transactionally so concurrent state-file
|
|
92
|
+
// writes to unrelated keys are not clobbered by a stale reload.
|
|
93
|
+
await updateState(projectRoot, (current) => {
|
|
94
|
+
if (!current.pendingResolution)
|
|
95
|
+
return current;
|
|
96
|
+
const next = { ...current };
|
|
97
|
+
delete next.pendingResolution;
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
});
|
|
77
101
|
success(`Resolved ${currentPatch.filename}`);
|
|
78
102
|
// Continue applying remaining patches
|
|
79
103
|
await runPatchLoop(projectRoot, session, paths, maxFuzz);
|
|
@@ -66,7 +66,8 @@ async function handleFreshStart(projectRoot, options) {
|
|
|
66
66
|
if (!(await confirmDirtyEngineReset({
|
|
67
67
|
engineDir: paths.engine,
|
|
68
68
|
yes: options.yes ?? false,
|
|
69
|
-
|
|
69
|
+
nonInteractiveCommand: 'fireforge rebase --yes',
|
|
70
|
+
argumentName: '--yes',
|
|
70
71
|
warningMessage: 'The engine directory has uncommitted changes that will be lost by the rebase.',
|
|
71
72
|
promptMessage: 'Discard uncommitted changes and start rebase?',
|
|
72
73
|
cancelMessage: 'Rebase cancelled',
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* Patch application loop and re-export flow.
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { updateState } from '../../core/config.js';
|
|
7
7
|
import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
|
|
8
8
|
import { applyPatchWithFuzz } from '../../core/patch-apply-fuzz.js';
|
|
9
9
|
import { updatePatch } from '../../core/patch-export.js';
|
|
10
10
|
import { discoverPatches } from '../../core/patch-files.js';
|
|
11
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
11
12
|
import { loadPatchesManifest, stampPatchVersions } from '../../core/patch-manifest.js';
|
|
12
13
|
import { extractConflictingFiles } from '../../core/patch-parse.js';
|
|
13
14
|
import { clearRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
15
|
+
import { runInSignalCriticalSection } from '../../core/signal-critical.js';
|
|
16
|
+
import { RebaseError } from '../../errors/rebase.js';
|
|
14
17
|
import { toError } from '../../utils/errors.js';
|
|
15
18
|
import { pathExists } from '../../utils/fs.js';
|
|
16
19
|
import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
|
|
@@ -34,35 +37,57 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
34
37
|
continue;
|
|
35
38
|
}
|
|
36
39
|
s.message(`Applying ${entry.filename}...`);
|
|
37
|
-
|
|
40
|
+
// Apply + session persist is wrapped in a signal-deferred critical
|
|
41
|
+
// section so a SIGINT / SIGTERM between the filesystem mutation and
|
|
42
|
+
// the session-file update is held until the bookkeeping write lands.
|
|
43
|
+
// Without this guard, `rebase --continue` could see a patch that is
|
|
44
|
+
// already in the engine but still marked pending, and would re-apply
|
|
45
|
+
// it on resume (either failing on duplicate hunks or producing
|
|
46
|
+
// divergent results).
|
|
47
|
+
const result = await runInSignalCriticalSection(`rebase-apply:${entry.filename}`, async () => {
|
|
48
|
+
const applyResult = await applyPatchWithFuzz(patchFile.path, paths.engine, maxFuzz);
|
|
49
|
+
if (applyResult.success) {
|
|
50
|
+
if (applyResult.fuzzFactor === 0) {
|
|
51
|
+
entry.status = 'applied-clean';
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
entry.status = 'applied-fuzz';
|
|
55
|
+
entry.fuzzFactor = applyResult.fuzzFactor;
|
|
56
|
+
}
|
|
57
|
+
session.currentIndex = i + 1;
|
|
58
|
+
await saveRebaseSession(projectRoot, session);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
entry.status = 'failed';
|
|
62
|
+
if (applyResult.error) {
|
|
63
|
+
entry.error = applyResult.error;
|
|
64
|
+
}
|
|
65
|
+
entry.conflictingFiles = extractConflictingFiles(applyResult.error);
|
|
66
|
+
session.currentIndex = i;
|
|
67
|
+
await saveRebaseSession(projectRoot, session);
|
|
68
|
+
}
|
|
69
|
+
return applyResult;
|
|
70
|
+
});
|
|
38
71
|
if (result.success) {
|
|
39
72
|
if (result.fuzzFactor === 0) {
|
|
40
|
-
entry.status = 'applied-clean';
|
|
41
73
|
success(` ${entry.filename} — applied cleanly`);
|
|
42
74
|
}
|
|
43
75
|
else {
|
|
44
|
-
entry.status = 'applied-fuzz';
|
|
45
|
-
entry.fuzzFactor = result.fuzzFactor;
|
|
46
76
|
warn(` ${entry.filename} — applied with fuzz=${result.fuzzFactor}`);
|
|
47
77
|
}
|
|
48
|
-
session.currentIndex = i + 1;
|
|
49
|
-
await saveRebaseSession(projectRoot, session);
|
|
50
78
|
}
|
|
51
79
|
else {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
originalError: result.error ?? 'Unknown error',
|
|
64
|
-
};
|
|
65
|
-
await saveState(projectRoot, state);
|
|
80
|
+
// Set pendingResolution in state for visibility. Kept outside the
|
|
81
|
+
// critical section — it is advisory UX, not a correctness invariant,
|
|
82
|
+
// and its absence would at most cause `fireforge status` to omit the
|
|
83
|
+
// pending-conflict hint until the next state write.
|
|
84
|
+
await updateState(projectRoot, (current) => ({
|
|
85
|
+
...current,
|
|
86
|
+
pendingResolution: {
|
|
87
|
+
patchFilename: entry.filename,
|
|
88
|
+
originalError: result.error ?? 'Unknown error',
|
|
89
|
+
},
|
|
90
|
+
}));
|
|
66
91
|
s.error(`${entry.filename} failed to apply`);
|
|
67
92
|
if (result.error) {
|
|
68
93
|
error(` Error: ${result.error}`);
|
|
@@ -79,8 +104,21 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
79
104
|
}
|
|
80
105
|
}
|
|
81
106
|
s.stop('All patches applied');
|
|
82
|
-
// Re-export all successfully applied patches
|
|
83
|
-
|
|
107
|
+
// Re-export all successfully applied patches. Failures here mean the
|
|
108
|
+
// engine has been rebased onto the new Firefox version but some .patch
|
|
109
|
+
// files were not refreshed — the queue would lie about what version each
|
|
110
|
+
// patch was tested against if we proceeded to stamp. Refuse to claim
|
|
111
|
+
// success and leave the session in place so the user can recover via
|
|
112
|
+
// `fireforge rebase --continue` after fixing the underlying cause.
|
|
113
|
+
const reExportFailures = await reExportAppliedPatches(session, paths);
|
|
114
|
+
if (reExportFailures.length > 0) {
|
|
115
|
+
for (const f of reExportFailures) {
|
|
116
|
+
error(` ${f.filename}: ${f.error}`);
|
|
117
|
+
}
|
|
118
|
+
throw new RebaseError(`Apply succeeded but ${reExportFailures.length} patch(es) failed to re-export. ` +
|
|
119
|
+
`Versions were not stamped and the rebase session has been kept so you can retry. ` +
|
|
120
|
+
`Fix the underlying cause shown above, then re-run "fireforge rebase --continue".`);
|
|
121
|
+
}
|
|
84
122
|
// Stamp versions
|
|
85
123
|
const appliedFilenames = session.patches
|
|
86
124
|
.filter((p) => p.status === 'applied-clean' || p.status === 'applied-fuzz' || p.status === 'resolved')
|
|
@@ -91,20 +129,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
91
129
|
// Print summary and clean up
|
|
92
130
|
printSummary(session);
|
|
93
131
|
await clearRebaseSession(projectRoot);
|
|
94
|
-
// Clear pending resolution if any
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
132
|
+
// Clear pending resolution if any (transactionally, so a concurrent
|
|
133
|
+
// state write to an unrelated key is not clobbered by a stale reload).
|
|
134
|
+
await updateState(projectRoot, (current) => {
|
|
135
|
+
if (!current.pendingResolution)
|
|
136
|
+
return current;
|
|
137
|
+
const next = { ...current };
|
|
138
|
+
delete next.pendingResolution;
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
100
141
|
info('');
|
|
101
142
|
success(`All patches re-exported with sourceEsrVersion=${session.toVersion}`);
|
|
102
143
|
outro('Rebase complete!');
|
|
103
144
|
}
|
|
104
145
|
async function reExportAppliedPatches(session, paths) {
|
|
146
|
+
const failures = [];
|
|
105
147
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
106
148
|
if (!manifest)
|
|
107
|
-
return;
|
|
149
|
+
return failures;
|
|
108
150
|
const s = spinner('Re-exporting patches...');
|
|
109
151
|
for (const entry of session.patches) {
|
|
110
152
|
if (entry.status !== 'applied-clean' && entry.status !== 'applied-fuzz')
|
|
@@ -123,13 +165,28 @@ async function reExportAppliedPatches(session, paths) {
|
|
|
123
165
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
124
166
|
if (diffContent.trim()) {
|
|
125
167
|
const patchPath = join(paths.patches, entry.filename);
|
|
126
|
-
|
|
168
|
+
// Hold the patch directory lock for the body rewrite so a concurrent
|
|
169
|
+
// manifest-mutating command (`resolve`, `re-export`, `patch compact`,
|
|
170
|
+
// `patch reorder`) cannot observe a torn patch body mid-write or
|
|
171
|
+
// persist metadata describing a body this loop is about to overwrite.
|
|
172
|
+
// Rebase sessions are serialized against each other by
|
|
173
|
+
// `rebase-session.ts`, so this lock is only defending against other
|
|
174
|
+
// command classes, not peer rebases.
|
|
175
|
+
await withPatchDirectoryLock(paths.patches, () => updatePatch(patchPath, diffContent));
|
|
127
176
|
}
|
|
128
177
|
}
|
|
129
178
|
catch (err) {
|
|
130
|
-
|
|
179
|
+
const message = toError(err).message;
|
|
180
|
+
warn(`Failed to re-export ${entry.filename}: ${message}`);
|
|
181
|
+
failures.push({ filename: entry.filename, error: message });
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
|
-
|
|
184
|
+
if (failures.length > 0) {
|
|
185
|
+
s.error(`Re-export failed for ${failures.length} patch(es)`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
s.stop('Patches re-exported');
|
|
189
|
+
}
|
|
190
|
+
return failures;
|
|
134
191
|
}
|
|
135
192
|
//# sourceMappingURL=patch-loop.js.map
|
|
@@ -15,6 +15,19 @@ import { pickDefined } from '../utils/options.js';
|
|
|
15
15
|
*/
|
|
16
16
|
export async function registerCommand(projectRoot, filePath, options = {}) {
|
|
17
17
|
intro('Register');
|
|
18
|
+
// --after is matched as a substring against existing manifest lines;
|
|
19
|
+
// guard against control characters / line terminators that would either
|
|
20
|
+
// break the match logic or, worse, inject a second line if the value
|
|
21
|
+
// is ever echoed back to a manifest writer. Null bytes are explicitly
|
|
22
|
+
// rejected to mirror the hardening already applied to other user-
|
|
23
|
+
// supplied identifiers (binaryName, furnace targetPath, …).
|
|
24
|
+
if (options.after !== undefined) {
|
|
25
|
+
// eslint-disable-next-line no-control-regex -- control chars in --after would break the manifest match
|
|
26
|
+
const hasControlChar = /[\u0000-\u001f]/.test(options.after);
|
|
27
|
+
if (options.after.length === 0 || hasControlChar) {
|
|
28
|
+
throw new InvalidArgumentError('--after must be a non-empty substring without control characters or line terminators.', 'after');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
18
31
|
// Verify the file exists in engine/ (skip for dry-run)
|
|
19
32
|
if (!options.dryRun) {
|
|
20
33
|
const paths = getProjectPaths(projectRoot);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
|
-
import { getProjectPaths, loadConfig, loadState,
|
|
4
|
+
import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
|
|
5
5
|
import { isGitRepository } from '../core/git.js';
|
|
6
6
|
import { getStagedDiffForFiles } from '../core/git-diff.js';
|
|
7
7
|
import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
|
|
8
|
-
import {
|
|
8
|
+
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
9
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, ResolutionError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -54,6 +54,17 @@ export async function resolveCommand(projectRoot) {
|
|
|
54
54
|
if (!patchMetadata) {
|
|
55
55
|
throw new ResolutionError(`Patch ${patchFilename} not found in manifest.`);
|
|
56
56
|
}
|
|
57
|
+
// Refuse to proceed if the patch file was deleted between the conflict
|
|
58
|
+
// and the resolve. Without this check, `updatePatchAndMetadata` would
|
|
59
|
+
// throw a less actionable "patch file is missing" error from inside
|
|
60
|
+
// the lock; the explicit precondition lets us point the user at the
|
|
61
|
+
// exact recovery path.
|
|
62
|
+
const patchPath = join(paths.patches, patchFilename);
|
|
63
|
+
if (!(await pathExists(patchPath))) {
|
|
64
|
+
throw new ResolutionError(`Patch file ${patchFilename} is missing on disk. ` +
|
|
65
|
+
'Delete the "pendingResolution" key from state.json to clear the stale conflict, ' +
|
|
66
|
+
'or restore the patch file before re-running resolve.');
|
|
67
|
+
}
|
|
57
68
|
const s = spinner(`Updating ${patchFilename}...`);
|
|
58
69
|
try {
|
|
59
70
|
const existingFiles = patchMetadata.filesAffected;
|
|
@@ -89,18 +100,28 @@ export async function resolveCommand(projectRoot) {
|
|
|
89
100
|
outro('Resolution unchanged');
|
|
90
101
|
return;
|
|
91
102
|
}
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
103
|
+
// Atomically write the patch body and the metadata update under the
|
|
104
|
+
// shared patch-directory lock. Replaces the previous lock-free
|
|
105
|
+
// sequence of updatePatch + updatePatchMetadata, which a concurrent
|
|
106
|
+
// import / export / re-export / patch reorder / patch compact could
|
|
107
|
+
// interleave with and leave the manifest disagreeing with the
|
|
108
|
+
// freshly-written patch body.
|
|
96
109
|
const config = await loadConfig(projectRoot);
|
|
97
|
-
await
|
|
110
|
+
await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
|
|
98
111
|
...(activeFiles.length < existingFiles.length ? { filesAffected: activeFiles } : {}),
|
|
99
112
|
sourceEsrVersion: config.firefox.version,
|
|
100
113
|
});
|
|
101
|
-
// Cleanup: Clear pendingResolution from state.json
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
// Cleanup: Clear pendingResolution from state.json transactionally so
|
|
115
|
+
// we don't clobber concurrent updates to unrelated keys (e.g. another
|
|
116
|
+
// command writing buildMode or baseCommit between our loadState and
|
|
117
|
+
// saveState). The updater function runs inside the state-file lock
|
|
118
|
+
// with the freshest on-disk state, so only pendingResolution is
|
|
119
|
+
// affected.
|
|
120
|
+
await updateState(projectRoot, (current) => {
|
|
121
|
+
const next = { ...current };
|
|
122
|
+
delete next.pendingResolution;
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
104
125
|
s.stop(`Updated ${patchFilename}`);
|
|
105
126
|
success('Patch updated successfully and resolution state cleared.');
|
|
106
127
|
info('Run "fireforge import" to apply the remaining patches.');
|