@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/bin/fireforge.js +19 -5
  3. package/dist/src/commands/config.js +7 -1
  4. package/dist/src/commands/discard.js +6 -1
  5. package/dist/src/commands/doctor.d.ts +12 -0
  6. package/dist/src/commands/doctor.js +6 -1
  7. package/dist/src/commands/download.js +106 -7
  8. package/dist/src/commands/export-shared.js +7 -0
  9. package/dist/src/commands/export.js +5 -0
  10. package/dist/src/commands/furnace/apply.js +147 -47
  11. package/dist/src/commands/furnace/create.js +13 -2
  12. package/dist/src/commands/furnace/deploy.js +17 -2
  13. package/dist/src/commands/furnace/diff.js +3 -1
  14. package/dist/src/commands/furnace/init.js +25 -7
  15. package/dist/src/commands/furnace/list.js +15 -7
  16. package/dist/src/commands/furnace/override.js +47 -15
  17. package/dist/src/commands/furnace/remove.js +68 -20
  18. package/dist/src/commands/furnace/rename.js +31 -3
  19. package/dist/src/commands/furnace/scan.js +8 -0
  20. package/dist/src/commands/furnace/validate.js +70 -7
  21. package/dist/src/commands/import.js +65 -11
  22. package/dist/src/commands/re-export.js +11 -4
  23. package/dist/src/commands/rebase/abort.js +26 -14
  24. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  25. package/dist/src/commands/rebase/confirm.js +2 -2
  26. package/dist/src/commands/rebase/continue.js +39 -15
  27. package/dist/src/commands/rebase/index.js +2 -1
  28. package/dist/src/commands/rebase/patch-loop.js +90 -33
  29. package/dist/src/commands/register.js +13 -0
  30. package/dist/src/commands/resolve.js +31 -10
  31. package/dist/src/commands/run.js +9 -44
  32. package/dist/src/commands/setup-support.js +25 -7
  33. package/dist/src/commands/status.js +59 -8
  34. package/dist/src/commands/test.js +13 -7
  35. package/dist/src/commands/token.js +11 -1
  36. package/dist/src/commands/watch.js +51 -1
  37. package/dist/src/commands/wire.js +23 -0
  38. package/dist/src/core/config-validate.js +15 -1
  39. package/dist/src/core/furnace-registration.d.ts +1 -1
  40. package/dist/src/core/furnace-registration.js +2 -1
  41. package/dist/src/core/furnace-staleness.d.ts +17 -0
  42. package/dist/src/core/furnace-staleness.js +58 -0
  43. package/dist/src/core/signal-critical.d.ts +49 -0
  44. package/dist/src/core/signal-critical.js +80 -0
  45. package/dist/src/errors/download.d.ts +1 -1
  46. package/dist/src/errors/download.js +6 -3
  47. 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, saveState } from '../core/config.js';
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
- verbose(`Treating ${file} as unmanaged because patched-content classification failed: ${toError(error).message}`);
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, state, projectRoot) {
92
+ async function handlePatchFailures(summary, projectRoot) {
68
93
  const firstFailed = summary.failed[0];
69
94
  if (firstFailed) {
70
- state.pendingResolution = {
71
- patchFilename: firstFailed.patch.filename,
72
- originalError: firstFailed.error ?? 'Unknown error',
73
- };
74
- await saveState(projectRoot, state);
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.\n');
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, state, projectRoot);
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 { updatePatch, updatePatchMetadata } from '../core/patch-export.js';
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
- const patchPath = join(paths.patches, patch.filename);
106
- await updatePatch(patchPath, diffContent);
107
- await updatePatchMetadata(paths.patches, patch.filename, {
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, loadState, saveState } from '../../core/config.js';
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
- nonInteractiveHint: 'Use: fireforge rebase --abort --yes',
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
- // Clear pending resolution state if any
49
- const state = await loadState(projectRoot);
50
- if (state.pendingResolution) {
51
- delete state.pendingResolution;
52
- await saveState(projectRoot, state);
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
- nonInteractiveHint: string;
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, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
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, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }) {
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('Engine has uncommitted changes and interactive confirmation is not available. Use --yes to proceed.', nonInteractiveHint);
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, loadState, saveState } from '../../core/config.js';
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 { updatePatch, updatePatchMetadata } from '../../core/patch-export.js';
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
- const patchPath = join(paths.patches, currentPatch.filename);
57
- await updatePatch(patchPath, diffContent);
58
- await updatePatchMetadata(paths.patches, currentPatch.filename, {
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
- currentPatch.status = 'resolved';
69
- session.currentIndex++;
70
- await saveRebaseSession(projectRoot, session);
71
- // Clear pending resolution
72
- const state = await loadState(projectRoot);
73
- if (state.pendingResolution) {
74
- delete state.pendingResolution;
75
- await saveState(projectRoot, state);
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
- nonInteractiveHint: 'Use: fireforge rebase --yes',
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 { loadState, saveState } from '../../core/config.js';
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
- const result = await applyPatchWithFuzz(patchFile.path, paths.engine, maxFuzz);
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
- entry.status = 'failed';
53
- if (result.error) {
54
- entry.error = result.error;
55
- }
56
- entry.conflictingFiles = extractConflictingFiles(result.error);
57
- session.currentIndex = i;
58
- await saveRebaseSession(projectRoot, session);
59
- // Set pendingResolution in state for visibility
60
- const state = await loadState(projectRoot);
61
- state.pendingResolution = {
62
- patchFilename: entry.filename,
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
- await reExportAppliedPatches(session, paths);
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
- const state = await loadState(projectRoot);
96
- if (state.pendingResolution) {
97
- delete state.pendingResolution;
98
- await saveState(projectRoot, state);
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
- await updatePatch(patchPath, diffContent);
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
- warn(`Failed to re-export ${entry.filename}: ${toError(err).message}`);
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
- s.stop('Patches re-exported');
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, saveState } from '../core/config.js';
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 { updatePatch, updatePatchMetadata } from '../core/patch-export.js';
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
- // Write the new diff content to the patch file
93
- const patchPath = join(paths.patches, patchFilename);
94
- await updatePatch(patchPath, diffContent);
95
- // Update metadata (preserve original createdAt)
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 updatePatchMetadata(paths.patches, patchFilename, {
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
- delete state.pendingResolution;
103
- await saveState(projectRoot, state);
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.');