@hominis/fireforge 0.10.1 → 0.11.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 +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch delete <name>` — removes a patch from the queue.
|
|
4
|
+
*
|
|
5
|
+
* Destructive: refuses when a later patch imports a module owned by the
|
|
6
|
+
* target (that would leave a dangling forward import), prompts for
|
|
7
|
+
* confirmation interactively, requires `--yes` for non-TTY, supports
|
|
8
|
+
* `--dry-run`, and appends to `patches/.fireforge-history.jsonl` on success.
|
|
9
|
+
*/
|
|
10
|
+
import { basename } from 'node:path';
|
|
11
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
12
|
+
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
13
|
+
import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
|
|
14
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
15
|
+
import { loadPatchesManifest, removePatchFileAndManifest } from '../../core/patch-manifest.js';
|
|
16
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
17
|
+
import { toError } from '../../utils/errors.js';
|
|
18
|
+
import { pathExists } from '../../utils/fs.js';
|
|
19
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
20
|
+
import { pickDefined } from '../../utils/options.js';
|
|
21
|
+
/**
|
|
22
|
+
* Resolves `<name>` (ordinal number or filename) to a manifest entry.
|
|
23
|
+
* Mirrors re-export's `resolvePatchIdentifier` so the two resolvers behave
|
|
24
|
+
* consistently — future work can lift this into a shared helper once a
|
|
25
|
+
* third consumer appears.
|
|
26
|
+
*/
|
|
27
|
+
function resolvePatchIdentifier(identifier, patches) {
|
|
28
|
+
if (/^\d+$/.test(identifier)) {
|
|
29
|
+
const order = parseInt(identifier, 10);
|
|
30
|
+
return patches.find((p) => p.order === order) ?? null;
|
|
31
|
+
}
|
|
32
|
+
const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
|
|
33
|
+
return patches.find((p) => p.filename === normalized) ?? null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Runs the `patch delete` command: removes a patch file and its manifest
|
|
37
|
+
* row atomically, refusing when a later patch imports a leaf owned by the
|
|
38
|
+
* target.
|
|
39
|
+
*
|
|
40
|
+
* @param projectRoot - Project root directory
|
|
41
|
+
* @param identifier - Patch filename or ordinal number to delete
|
|
42
|
+
* @param options - Command options
|
|
43
|
+
*/
|
|
44
|
+
export async function patchDeleteCommand(projectRoot, identifier, options = {}) {
|
|
45
|
+
intro(options.dryRun ? 'FireForge patch delete (dry run)' : 'FireForge patch delete');
|
|
46
|
+
const paths = getProjectPaths(projectRoot);
|
|
47
|
+
if (!(await pathExists(paths.patches))) {
|
|
48
|
+
throw new GeneralError('Patches directory not found. No patches to delete.');
|
|
49
|
+
}
|
|
50
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
51
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
52
|
+
throw new GeneralError('No patches in manifest.');
|
|
53
|
+
}
|
|
54
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
55
|
+
if (!target) {
|
|
56
|
+
throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
|
|
57
|
+
}
|
|
58
|
+
// Build the full queue context once so we can scan each patch's newFiles
|
|
59
|
+
// without re-parsing for the dependency check below.
|
|
60
|
+
const baseCtx = await buildPatchQueueContext(paths.patches);
|
|
61
|
+
// Hard refusal: run the forward-import rule against the projected state.
|
|
62
|
+
// Any issue that names the target patch in its message still applies; any
|
|
63
|
+
// new forward-import that appears *only because the target is gone* means
|
|
64
|
+
// another patch was depending on the target's newly-created files.
|
|
65
|
+
// Simpler check: run the rule on the *original* context and look for
|
|
66
|
+
// imports that resolve into the target's new files from earlier patches.
|
|
67
|
+
// Even simpler: an import owned by a later patch pointing at any of the
|
|
68
|
+
// target's newly-created files is a dependency on the target. We build
|
|
69
|
+
// that check directly from baseCtx.
|
|
70
|
+
const targetEntry = baseCtx.entries.find((e) => e.filename === target.filename);
|
|
71
|
+
const targetNewFileLeaves = new Set();
|
|
72
|
+
if (targetEntry) {
|
|
73
|
+
for (const fullPath of targetEntry.newFiles.keys()) {
|
|
74
|
+
targetNewFileLeaves.add(basename(fullPath));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Scan every later patch's new files AND its added lines on pre-existing
|
|
78
|
+
// files for import specifiers that resolve to a leaf owned by the target.
|
|
79
|
+
// Uses the shared specifier extractor so dynamic import() and
|
|
80
|
+
// ChromeUtils.defineESModuleGetters are picked up — the forward-import
|
|
81
|
+
// lint rule already covers those forms and delete safety must match the
|
|
82
|
+
// same set or it silently drops dependencies.
|
|
83
|
+
//
|
|
84
|
+
// We cover both source-site maps: `newFiles` (files the later patch
|
|
85
|
+
// creates) and `modifiedFileAdditions` (added lines against files that
|
|
86
|
+
// already exist). Scanning only newFiles was the second-degree miss
|
|
87
|
+
// that motivated this change — a later patch could add
|
|
88
|
+
// `import "./TargetHelper.sys.mjs"` to an existing file and the delete
|
|
89
|
+
// guard would never see the dependency.
|
|
90
|
+
const dependents = [];
|
|
91
|
+
const scanSite = (entryFilename, sitePath, content) => {
|
|
92
|
+
if (!isForwardImportableFile(sitePath))
|
|
93
|
+
return false;
|
|
94
|
+
const ignoredLines = findForwardImportIgnoreLines(content);
|
|
95
|
+
const specifiers = extractImportSpecifiersWithLines(content);
|
|
96
|
+
for (const { specifier, line } of specifiers) {
|
|
97
|
+
if (ignoredLines.has(line))
|
|
98
|
+
continue;
|
|
99
|
+
const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
|
|
100
|
+
const leaf = basename(cleaned);
|
|
101
|
+
if (!leaf || !isForwardImportableFile(leaf))
|
|
102
|
+
continue;
|
|
103
|
+
if (targetNewFileLeaves.has(leaf)) {
|
|
104
|
+
dependents.push(`${entryFilename} (${sitePath}) imports "${specifier}" which would be deleted`);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
};
|
|
110
|
+
for (const entry of baseCtx.entries) {
|
|
111
|
+
if (entry.filename === target.filename)
|
|
112
|
+
continue;
|
|
113
|
+
if (entry.order < target.order)
|
|
114
|
+
continue;
|
|
115
|
+
let matched = false;
|
|
116
|
+
for (const [newFile, content] of entry.newFiles) {
|
|
117
|
+
if (scanSite(entry.filename, newFile, content)) {
|
|
118
|
+
matched = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (matched)
|
|
123
|
+
continue;
|
|
124
|
+
for (const [modifiedPath, addedContent] of entry.modifiedFileAdditions) {
|
|
125
|
+
if (scanSite(entry.filename, modifiedPath, addedContent))
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const conflicts = dependents.length > 0
|
|
130
|
+
? {
|
|
131
|
+
reason: `${dependents.length} later patch(es) depend on files created by ${target.filename}`,
|
|
132
|
+
details: dependents,
|
|
133
|
+
}
|
|
134
|
+
: null;
|
|
135
|
+
const summary = [
|
|
136
|
+
`delete ${target.filename} (category: ${target.category}, order: ${target.order})`,
|
|
137
|
+
`description: ${target.description || '(none)'}`,
|
|
138
|
+
`files currently claimed by this patch (${target.filesAffected.length}):`,
|
|
139
|
+
];
|
|
140
|
+
for (const file of target.filesAffected) {
|
|
141
|
+
summary.push(` ${file} → will become unmanaged`);
|
|
142
|
+
}
|
|
143
|
+
const decision = await confirmDestructive({
|
|
144
|
+
operation: 'patch-delete',
|
|
145
|
+
title: `Delete ${target.filename}`,
|
|
146
|
+
summary,
|
|
147
|
+
yes: options.yes === true,
|
|
148
|
+
dryRun: options.dryRun === true,
|
|
149
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
150
|
+
conflicts,
|
|
151
|
+
});
|
|
152
|
+
if (decision === 'dry-run') {
|
|
153
|
+
outro('Dry run complete — no changes made');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (decision === 'cancelled') {
|
|
157
|
+
outro('Delete cancelled');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Proceed: remove under the patch directory lock so concurrent exports
|
|
161
|
+
// cannot race us into the same manifest row. The history append lives
|
|
162
|
+
// inside the same lock so two concurrent deletes cannot interleave
|
|
163
|
+
// history records beyond what POSIX O_APPEND atomicity guarantees for a
|
|
164
|
+
// single record, and so a crash between mutation and history write
|
|
165
|
+
// cannot leave a committed mutation with no audit trail alongside a
|
|
166
|
+
// concurrent mutation's record appearing first. A history append
|
|
167
|
+
// failure is warned but not re-thrown: by that point the mutation
|
|
168
|
+
// has committed and reporting failure to the caller would mislead.
|
|
169
|
+
await withPatchDirectoryLock(paths.patches, async () => {
|
|
170
|
+
await removePatchFileAndManifest(paths.patches, target.filename);
|
|
171
|
+
try {
|
|
172
|
+
await appendHistory(paths.patches, {
|
|
173
|
+
operation: 'patch-delete',
|
|
174
|
+
args: {
|
|
175
|
+
filename: target.filename,
|
|
176
|
+
order: target.order,
|
|
177
|
+
filesAffected: target.filesAffected,
|
|
178
|
+
},
|
|
179
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
180
|
+
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
181
|
+
result: 'ok',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (historyError) {
|
|
185
|
+
warn(`History log append failed after patch delete committed (${target.filename}): ${toError(historyError).message}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
info(`Deleted ${target.filename}.`);
|
|
189
|
+
outro('Delete complete');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Registers the `patch delete` subcommand on the `patch` parent.
|
|
193
|
+
*
|
|
194
|
+
* @param parent - Parent Commander command
|
|
195
|
+
* @param context - Shared CLI registration context
|
|
196
|
+
*/
|
|
197
|
+
export function registerPatchDelete(parent, context) {
|
|
198
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
199
|
+
parent
|
|
200
|
+
.command('delete <name>')
|
|
201
|
+
.description('Delete a patch from the queue (destructive)')
|
|
202
|
+
.option('--dry-run', 'Show what would happen without writing')
|
|
203
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
204
|
+
.option('--force-unsafe', 'Bypass the refusal when a later patch depends on this patch (last resort)')
|
|
205
|
+
.action(withErrorHandling(async (name, options) => {
|
|
206
|
+
await patchDeleteCommand(getProjectRoot(), name, pickDefined(options));
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=delete.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge patch <verb>` parent command. Groups single-patch
|
|
3
|
+
* mutations (`delete`, `reorder`) so they do not clutter the top-level
|
|
4
|
+
* command list. Queue-level verbs like `lint`, `export`, `verify`, and
|
|
5
|
+
* `status` stay flat.
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import type { CommandContext } from '../../types/cli.js';
|
|
9
|
+
export { patchDeleteCommand } from './delete.js';
|
|
10
|
+
export { patchReorderCommand } from './reorder.js';
|
|
11
|
+
/**
|
|
12
|
+
* Registers the `patch` subcommand parent and its verbs on the CLI.
|
|
13
|
+
*
|
|
14
|
+
* @param program - Commander root program
|
|
15
|
+
* @param context - Shared CLI registration context
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerPatch(program: Command, context: CommandContext): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch <verb>` parent command. Groups single-patch
|
|
4
|
+
* mutations (`delete`, `reorder`) so they do not clutter the top-level
|
|
5
|
+
* command list. Queue-level verbs like `lint`, `export`, `verify`, and
|
|
6
|
+
* `status` stay flat.
|
|
7
|
+
*/
|
|
8
|
+
import { registerPatchDelete } from './delete.js';
|
|
9
|
+
import { registerPatchReorder } from './reorder.js';
|
|
10
|
+
export { patchDeleteCommand } from './delete.js';
|
|
11
|
+
export { patchReorderCommand } from './reorder.js';
|
|
12
|
+
/**
|
|
13
|
+
* Registers the `patch` subcommand parent and its verbs on the CLI.
|
|
14
|
+
*
|
|
15
|
+
* @param program - Commander root program
|
|
16
|
+
* @param context - Shared CLI registration context
|
|
17
|
+
*/
|
|
18
|
+
export function registerPatch(program, context) {
|
|
19
|
+
const patch = program
|
|
20
|
+
.command('patch')
|
|
21
|
+
.description('Manage individual patches in the queue (delete, reorder)');
|
|
22
|
+
registerPatchDelete(patch, context);
|
|
23
|
+
registerPatchReorder(patch, context);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge patch reorder <name> --to <N> | --before <name> | --after <name>`
|
|
3
|
+
*
|
|
4
|
+
* Renames the target .patch file and rewrites manifest rows so the target
|
|
5
|
+
* moves to the requested ordinal slot. Any subsequent patches are
|
|
6
|
+
* renumbered to make room. Pre-flights the projected order through
|
|
7
|
+
* `lintPatchQueue` so reorders that would introduce a forward-import fail
|
|
8
|
+
* before any bytes move.
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from 'commander';
|
|
11
|
+
import type { CommandContext } from '../../types/cli.js';
|
|
12
|
+
import type { PatchReorderOptions } from '../../types/commands/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Runs the `patch reorder` command: computes a rename map moving the
|
|
15
|
+
* target patch to the requested slot, projects the new order through
|
|
16
|
+
* cross-patch lint, confirms, and then renames under the patch directory
|
|
17
|
+
* lock.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot - Project root directory
|
|
20
|
+
* @param identifier - Patch filename or ordinal number to move
|
|
21
|
+
* @param options - Command options (mutually exclusive --to/--before/--after)
|
|
22
|
+
*/
|
|
23
|
+
export declare function patchReorderCommand(projectRoot: string, identifier: string, options?: PatchReorderOptions): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Registers the `patch reorder` subcommand on the `patch` parent.
|
|
26
|
+
*
|
|
27
|
+
* @param parent - Parent Commander command
|
|
28
|
+
* @param context - Shared CLI registration context
|
|
29
|
+
*/
|
|
30
|
+
export declare function registerPatchReorder(parent: Command, context: CommandContext): void;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch reorder <name> --to <N> | --before <name> | --after <name>`
|
|
4
|
+
*
|
|
5
|
+
* Renames the target .patch file and rewrites manifest rows so the target
|
|
6
|
+
* moves to the requested ordinal slot. Any subsequent patches are
|
|
7
|
+
* renumbered to make room. Pre-flights the projected order through
|
|
8
|
+
* `lintPatchQueue` so reorders that would introduce a forward-import fail
|
|
9
|
+
* before any bytes move.
|
|
10
|
+
*/
|
|
11
|
+
import { Option } from 'commander';
|
|
12
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
13
|
+
import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
|
|
14
|
+
import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
15
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
16
|
+
import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
|
|
17
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
18
|
+
import { toError } from '../../utils/errors.js';
|
|
19
|
+
import { pathExists } from '../../utils/fs.js';
|
|
20
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
21
|
+
import { pickDefined } from '../../utils/options.js';
|
|
22
|
+
import { parsePositiveIntegerFlag } from '../../utils/validation.js';
|
|
23
|
+
function resolvePatchIdentifier(identifier, patches) {
|
|
24
|
+
if (/^\d+$/.test(identifier)) {
|
|
25
|
+
const order = parseInt(identifier, 10);
|
|
26
|
+
return patches.find((p) => p.order === order) ?? null;
|
|
27
|
+
}
|
|
28
|
+
const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
|
|
29
|
+
return patches.find((p) => p.filename === normalized) ?? null;
|
|
30
|
+
}
|
|
31
|
+
function padOrder(value, width) {
|
|
32
|
+
return String(value).padStart(width, '0');
|
|
33
|
+
}
|
|
34
|
+
function rebuildFilenameForOrder(existing, newOrder) {
|
|
35
|
+
const currentPrefixMatch = /^(\d+)-/.exec(existing.filename);
|
|
36
|
+
const currentPrefix = currentPrefixMatch?.[1] ?? '001';
|
|
37
|
+
const width = Math.max(3, currentPrefix.length, String(newOrder).length);
|
|
38
|
+
const rest = existing.filename.replace(/^\d+-/, '');
|
|
39
|
+
return `${padOrder(newOrder, width)}-${rest}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Computes a rename map that moves `target` to `destinationOrder` with a
|
|
43
|
+
* minimal shift: only the contiguous run of patches whose current order
|
|
44
|
+
* blocks the destination slot is renumbered, so intentional gaps left by
|
|
45
|
+
* prior `patch delete` calls survive the reorder.
|
|
46
|
+
*
|
|
47
|
+
* Algorithm: remove target from the sorted list, then cascade: if any
|
|
48
|
+
* patch sits at the destination order, bump it to order+1; if that
|
|
49
|
+
* collides with the next patch, bump that one too; continue until a free
|
|
50
|
+
* slot is reached. The direction is symmetric — moving a patch earlier
|
|
51
|
+
* and moving it later both reduce to "find a free slot at destination by
|
|
52
|
+
* shifting the contiguous conflicting run upward".
|
|
53
|
+
*/
|
|
54
|
+
function computeRenameMap(manifestPatches, target, destinationOrder) {
|
|
55
|
+
const renames = new Map();
|
|
56
|
+
if (destinationOrder === target.order)
|
|
57
|
+
return renames;
|
|
58
|
+
const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
|
|
59
|
+
const withoutTarget = sorted.filter((p) => p.filename !== target.filename);
|
|
60
|
+
// Clamp destination into the meaningful range. `--to 0` snaps to
|
|
61
|
+
// minOrder, `--to 99` to maxOrder+1 (append past the tail).
|
|
62
|
+
const minOrder = Math.min(...sorted.map((p) => p.order));
|
|
63
|
+
const maxOrderAfterRemoval = withoutTarget.length > 0 ? Math.max(...withoutTarget.map((p) => p.order)) : minOrder;
|
|
64
|
+
const clampedDest = Math.max(minOrder, Math.min(destinationOrder, maxOrderAfterRemoval + 1));
|
|
65
|
+
if (clampedDest === target.order)
|
|
66
|
+
return renames;
|
|
67
|
+
// Build a mutable order-for-each map so cascading bumps compose. Keys are
|
|
68
|
+
// filenames; values start as current order and get rewritten as bumps
|
|
69
|
+
// propagate. Only patches whose value changes end up in the rename map.
|
|
70
|
+
const currentOrder = new Map();
|
|
71
|
+
for (const patch of withoutTarget)
|
|
72
|
+
currentOrder.set(patch.filename, patch.order);
|
|
73
|
+
// Cascade from the destination: while any surviving patch occupies the
|
|
74
|
+
// slot we want, bump it to slot+1 and advance. Patches are processed in
|
|
75
|
+
// ascending order so each bump's collision (if any) is with the immediate
|
|
76
|
+
// successor in the sort — no back-tracking needed.
|
|
77
|
+
let slot = clampedDest;
|
|
78
|
+
for (const patch of withoutTarget) {
|
|
79
|
+
const order = currentOrder.get(patch.filename);
|
|
80
|
+
if (order === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
if (order < slot)
|
|
83
|
+
continue;
|
|
84
|
+
if (order > slot)
|
|
85
|
+
break;
|
|
86
|
+
// Collision at `slot`: bump this patch to slot+1 and continue scanning,
|
|
87
|
+
// because slot+1 may also be occupied by the next patch in sequence.
|
|
88
|
+
currentOrder.set(patch.filename, slot + 1);
|
|
89
|
+
slot = slot + 1;
|
|
90
|
+
}
|
|
91
|
+
for (const patch of withoutTarget) {
|
|
92
|
+
const newOrder = currentOrder.get(patch.filename);
|
|
93
|
+
if (newOrder === undefined || newOrder === patch.order)
|
|
94
|
+
continue;
|
|
95
|
+
renames.set(patch.filename, {
|
|
96
|
+
newOrder,
|
|
97
|
+
newFilename: rebuildFilenameForOrder(patch, newOrder),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
renames.set(target.filename, {
|
|
101
|
+
newOrder: clampedDest,
|
|
102
|
+
newFilename: rebuildFilenameForOrder(target, clampedDest),
|
|
103
|
+
});
|
|
104
|
+
return renames;
|
|
105
|
+
}
|
|
106
|
+
function getSortedRenameEntries(renameMap) {
|
|
107
|
+
return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
|
|
108
|
+
}
|
|
109
|
+
function renameMapsEqual(left, right) {
|
|
110
|
+
const leftEntries = getSortedRenameEntries(left);
|
|
111
|
+
const rightEntries = getSortedRenameEntries(right);
|
|
112
|
+
if (leftEntries.length !== rightEntries.length) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return leftEntries.every(([leftFilename, leftEntry], index) => {
|
|
116
|
+
const rightTuple = rightEntries[index];
|
|
117
|
+
if (!rightTuple)
|
|
118
|
+
return false;
|
|
119
|
+
const [rightFilename, rightEntry] = rightTuple;
|
|
120
|
+
return (leftFilename === rightFilename &&
|
|
121
|
+
leftEntry.newFilename === rightEntry.newFilename &&
|
|
122
|
+
leftEntry.newOrder === rightEntry.newOrder);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Applies a rename map to a {@link PatchQueueContext} so cross-patch lint
|
|
127
|
+
* can run against the projected state without touching disk.
|
|
128
|
+
*/
|
|
129
|
+
function projectReorder(base, renameMap) {
|
|
130
|
+
const projectedEntries = base.entries.map((entry) => {
|
|
131
|
+
const rename = renameMap.get(entry.filename);
|
|
132
|
+
if (!rename)
|
|
133
|
+
return entry;
|
|
134
|
+
return {
|
|
135
|
+
...entry,
|
|
136
|
+
filename: rename.newFilename,
|
|
137
|
+
order: rename.newOrder,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
|
|
141
|
+
return { entries: projectedEntries };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolves `--to <N>`, `--before <anchor>`, and `--after <anchor>` into a
|
|
145
|
+
* concrete destination order and, for the anchor variants, the anchor
|
|
146
|
+
* filename the reorder should position against. Extracted from
|
|
147
|
+
* {@link patchReorderCommand} so the command body stays inside the
|
|
148
|
+
* project's per-function line budget.
|
|
149
|
+
*/
|
|
150
|
+
function resolveDestination(target, manifestPatches, options) {
|
|
151
|
+
if (options.to !== undefined) {
|
|
152
|
+
// Defense-in-depth: the argParser should have rejected non-positive
|
|
153
|
+
// integers, but the function is reachable from tests that may pass
|
|
154
|
+
// `{ to: NaN }` directly.
|
|
155
|
+
if (!Number.isInteger(options.to) || options.to <= 0) {
|
|
156
|
+
throw new InvalidArgumentError(`--to must be a positive integer, got ${String(options.to)}.`, '--to');
|
|
157
|
+
}
|
|
158
|
+
return { destinationOrder: options.to, anchorFilename: undefined };
|
|
159
|
+
}
|
|
160
|
+
if (options.before !== undefined) {
|
|
161
|
+
const anchor = resolvePatchIdentifier(options.before, manifestPatches);
|
|
162
|
+
if (!anchor) {
|
|
163
|
+
throw new InvalidArgumentError(`--before anchor "${options.before}" not found.`, '--before');
|
|
164
|
+
}
|
|
165
|
+
// Reject self-reference. `--before <target>` resolves to the target's
|
|
166
|
+
// current order, so computeRenameMap would take its no-op branch — but
|
|
167
|
+
// that masks a user-facing typo or scripted mistake instead of
|
|
168
|
+
// surfacing it. The symmetric `--after` case is worse (mutates the
|
|
169
|
+
// queue), so both reject for consistency.
|
|
170
|
+
if (anchor.filename === target.filename) {
|
|
171
|
+
throw new InvalidArgumentError(`Cannot reorder patch "${target.filename}" relative to itself.`, '--before');
|
|
172
|
+
}
|
|
173
|
+
return { destinationOrder: anchor.order, anchorFilename: anchor.filename };
|
|
174
|
+
}
|
|
175
|
+
const afterId = options.after;
|
|
176
|
+
if (afterId === undefined) {
|
|
177
|
+
throw new InvalidArgumentError('Reached --after resolver with no value set.', '--after');
|
|
178
|
+
}
|
|
179
|
+
const anchor = resolvePatchIdentifier(afterId, manifestPatches);
|
|
180
|
+
if (!anchor) {
|
|
181
|
+
throw new InvalidArgumentError(`--after anchor "${afterId}" not found.`, '--after');
|
|
182
|
+
}
|
|
183
|
+
// See the --before branch above: self-reference is a logical
|
|
184
|
+
// contradiction. In the --after case, the previous `anchor.order + 1`
|
|
185
|
+
// bypassed computeRenameMap's no-op short-circuit and silently
|
|
186
|
+
// renumbered the target and every patch after it.
|
|
187
|
+
if (anchor.filename === target.filename) {
|
|
188
|
+
throw new InvalidArgumentError(`Cannot reorder patch "${target.filename}" relative to itself.`, '--after');
|
|
189
|
+
}
|
|
190
|
+
return { destinationOrder: anchor.order + 1, anchorFilename: anchor.filename };
|
|
191
|
+
}
|
|
192
|
+
async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, buildHistoryEntry) {
|
|
193
|
+
await withPatchDirectoryLock(patchesDir, async () => {
|
|
194
|
+
const currentManifest = await loadPatchesManifest(patchesDir);
|
|
195
|
+
if (!currentManifest || currentManifest.patches.length === 0) {
|
|
196
|
+
throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder.');
|
|
197
|
+
}
|
|
198
|
+
const currentTarget = currentManifest.patches.find((p) => p.filename === target.filename);
|
|
199
|
+
if (!currentTarget) {
|
|
200
|
+
throw new GeneralError(`Patch queue changed while waiting for confirmation. ${target.filename} no longer exists; re-run reorder.`);
|
|
201
|
+
}
|
|
202
|
+
let currentDestinationOrder;
|
|
203
|
+
if (options.to !== undefined) {
|
|
204
|
+
currentDestinationOrder = options.to;
|
|
205
|
+
}
|
|
206
|
+
else if (options.before !== undefined) {
|
|
207
|
+
const currentAnchor = currentManifest.patches.find((p) => p.filename === anchorFilename);
|
|
208
|
+
if (!currentAnchor) {
|
|
209
|
+
throw new GeneralError('Patch queue changed while waiting for confirmation. The reorder anchor moved or disappeared; re-run reorder.');
|
|
210
|
+
}
|
|
211
|
+
currentDestinationOrder = currentAnchor.order;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const currentAnchor = currentManifest.patches.find((p) => p.filename === anchorFilename);
|
|
215
|
+
if (!currentAnchor) {
|
|
216
|
+
throw new GeneralError('Patch queue changed while waiting for confirmation. The reorder anchor moved or disappeared; re-run reorder.');
|
|
217
|
+
}
|
|
218
|
+
currentDestinationOrder = currentAnchor.order + 1;
|
|
219
|
+
}
|
|
220
|
+
const currentRenameMap = computeRenameMap(currentManifest.patches, currentTarget, currentDestinationOrder);
|
|
221
|
+
if (!renameMapsEqual(renameMap, currentRenameMap)) {
|
|
222
|
+
throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder to recompute the rename plan.');
|
|
223
|
+
}
|
|
224
|
+
const currentProjected = projectReorder(await buildPatchQueueContext(patchesDir), currentRenameMap);
|
|
225
|
+
const currentConflicts = lintPatchQueue(currentProjected).filter((i) => i.severity === 'error');
|
|
226
|
+
if (currentConflicts.length > 0 && options.forceUnsafe !== true) {
|
|
227
|
+
throw new InvalidArgumentError(`Refusing to run patch reorder: reorder would introduce ${currentConflicts.length} cross-patch lint error(s). Pass --force-unsafe to override.`, '--force-unsafe');
|
|
228
|
+
}
|
|
229
|
+
await renumberPatchesInManifest(patchesDir, currentRenameMap);
|
|
230
|
+
// Append the history record inside the lock so two concurrent
|
|
231
|
+
// reorders cannot interleave mutation and history writes, and so a
|
|
232
|
+
// crash between the rename and the history write cannot orphan a
|
|
233
|
+
// committed reorder with no audit trail. If the append itself
|
|
234
|
+
// fails (disk full, permissions), we warn but do not re-throw:
|
|
235
|
+
// the mutation has already succeeded and is not reversible, so
|
|
236
|
+
// surfacing the history failure as a command failure would
|
|
237
|
+
// mislead the caller.
|
|
238
|
+
try {
|
|
239
|
+
await appendHistory(patchesDir, buildHistoryEntry(currentRenameMap));
|
|
240
|
+
}
|
|
241
|
+
catch (historyError) {
|
|
242
|
+
warn(`History log append failed after patch reorder committed: ${toError(historyError).message}`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Runs the `patch reorder` command: computes a rename map moving the
|
|
248
|
+
* target patch to the requested slot, projects the new order through
|
|
249
|
+
* cross-patch lint, confirms, and then renames under the patch directory
|
|
250
|
+
* lock.
|
|
251
|
+
*
|
|
252
|
+
* @param projectRoot - Project root directory
|
|
253
|
+
* @param identifier - Patch filename or ordinal number to move
|
|
254
|
+
* @param options - Command options (mutually exclusive --to/--before/--after)
|
|
255
|
+
*/
|
|
256
|
+
export async function patchReorderCommand(projectRoot, identifier, options = {}) {
|
|
257
|
+
intro(options.dryRun ? 'FireForge patch reorder (dry run)' : 'FireForge patch reorder');
|
|
258
|
+
const specifiedTargets = [
|
|
259
|
+
options.to !== undefined,
|
|
260
|
+
options.before !== undefined,
|
|
261
|
+
options.after !== undefined,
|
|
262
|
+
].filter(Boolean).length;
|
|
263
|
+
if (specifiedTargets === 0) {
|
|
264
|
+
throw new InvalidArgumentError('Specify --to <N>, --before <name>, or --after <name>.', 'patch reorder');
|
|
265
|
+
}
|
|
266
|
+
if (specifiedTargets > 1) {
|
|
267
|
+
throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
|
|
268
|
+
}
|
|
269
|
+
const paths = getProjectPaths(projectRoot);
|
|
270
|
+
if (!(await pathExists(paths.patches))) {
|
|
271
|
+
throw new GeneralError('Patches directory not found.');
|
|
272
|
+
}
|
|
273
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
274
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
275
|
+
throw new GeneralError('No patches in manifest.');
|
|
276
|
+
}
|
|
277
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
278
|
+
if (!target) {
|
|
279
|
+
throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
|
|
280
|
+
}
|
|
281
|
+
const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
|
|
282
|
+
const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
|
|
283
|
+
if (renameMap.size === 0) {
|
|
284
|
+
info('Target is already at the requested position. Nothing to do.');
|
|
285
|
+
outro('Reorder complete (no-op)');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Project the reorder through cross-patch lint. Forward-import violations
|
|
289
|
+
// that are introduced *by* the reorder become hard refusals.
|
|
290
|
+
const baseCtx = await buildPatchQueueContext(paths.patches);
|
|
291
|
+
const projected = projectReorder(baseCtx, renameMap);
|
|
292
|
+
const projectedIssues = lintPatchQueue(projected);
|
|
293
|
+
const errorIssues = projectedIssues.filter((i) => i.severity === 'error');
|
|
294
|
+
const conflicts = errorIssues.length > 0
|
|
295
|
+
? {
|
|
296
|
+
reason: `reorder would introduce ${errorIssues.length} cross-patch lint error(s)`,
|
|
297
|
+
details: errorIssues.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
|
|
298
|
+
}
|
|
299
|
+
: null;
|
|
300
|
+
const renameEntries = getSortedRenameEntries(renameMap);
|
|
301
|
+
const targetRename = renameMap.get(target.filename);
|
|
302
|
+
if (!targetRename) {
|
|
303
|
+
throw new GeneralError('Reorder plan did not include the target patch.');
|
|
304
|
+
}
|
|
305
|
+
const actualDestinationOrder = targetRename.newOrder;
|
|
306
|
+
const summary = [
|
|
307
|
+
`move ${target.filename} → order ${actualDestinationOrder}`,
|
|
308
|
+
`${renameMap.size} patch(es) would be renamed:`,
|
|
309
|
+
];
|
|
310
|
+
for (const [oldFilename, entry] of renameEntries) {
|
|
311
|
+
summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
|
|
312
|
+
}
|
|
313
|
+
const decision = await confirmDestructive({
|
|
314
|
+
operation: 'patch-reorder',
|
|
315
|
+
title: `Reorder ${target.filename} to position ${actualDestinationOrder}`,
|
|
316
|
+
summary,
|
|
317
|
+
yes: options.yes === true,
|
|
318
|
+
dryRun: options.dryRun === true,
|
|
319
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
320
|
+
conflicts,
|
|
321
|
+
});
|
|
322
|
+
if (decision === 'dry-run') {
|
|
323
|
+
outro('Dry run complete — no changes made');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (decision === 'cancelled') {
|
|
327
|
+
outro('Reorder cancelled');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// The history entry is built inside commitReorderPlan (still under the
|
|
331
|
+
// lock) from the *final* rename map, not the pre-confirmation one, so
|
|
332
|
+
// the destinationOrder and renames mirror what actually landed on disk.
|
|
333
|
+
const buildHistoryEntry = (finalRenameMap) => {
|
|
334
|
+
const finalEntries = getSortedRenameEntries(finalRenameMap);
|
|
335
|
+
const finalTarget = finalRenameMap.get(target.filename);
|
|
336
|
+
return {
|
|
337
|
+
operation: 'patch-reorder',
|
|
338
|
+
args: {
|
|
339
|
+
target: target.filename,
|
|
340
|
+
destinationOrder: finalTarget?.newOrder ?? actualDestinationOrder,
|
|
341
|
+
renames: finalEntries.map(([from, entry]) => ({
|
|
342
|
+
from,
|
|
343
|
+
to: entry.newFilename,
|
|
344
|
+
order: entry.newOrder,
|
|
345
|
+
})),
|
|
346
|
+
},
|
|
347
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
348
|
+
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
349
|
+
result: 'ok',
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, buildHistoryEntry);
|
|
353
|
+
info(`Reordered ${renameMap.size} patch(es).`);
|
|
354
|
+
outro('Reorder complete');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Registers the `patch reorder` subcommand on the `patch` parent.
|
|
358
|
+
*
|
|
359
|
+
* @param parent - Parent Commander command
|
|
360
|
+
* @param context - Shared CLI registration context
|
|
361
|
+
*/
|
|
362
|
+
export function registerPatchReorder(parent, context) {
|
|
363
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
364
|
+
parent
|
|
365
|
+
.command('reorder <name>')
|
|
366
|
+
.description('Move a patch to a different position in the queue (destructive)')
|
|
367
|
+
.addOption(new Option('--to <order>', 'Destination ordinal').argParser((v) => parsePositiveIntegerFlag('--to', v)))
|
|
368
|
+
.option('--before <anchor>', 'Place the patch immediately before <anchor>')
|
|
369
|
+
.option('--after <anchor>', 'Place the patch immediately after <anchor>')
|
|
370
|
+
.option('--dry-run', 'Show what would happen without writing')
|
|
371
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
372
|
+
.option('--force-unsafe', 'Bypass the refusal when the projected order introduces a lint error')
|
|
373
|
+
.action(withErrorHandling(async (name, options) => {
|
|
374
|
+
await patchReorderCommand(getProjectRoot(), name, pickDefined(options));
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
//# sourceMappingURL=reorder.js.map
|