@hominis/fireforge 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
4
+ * a single patch without rewriting the `.patch` file body.
5
+ *
6
+ * Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
7
+ * the right tool when the patch body itself needs to be regenerated; this
8
+ * subcommand exists for the metadata-only adjustment, where the operator
9
+ * has discovered (e.g. from a `lint --per-patch` warning) that the
10
+ * threshold-tier override should be set but the patch body is already
11
+ * correct. Avoiding the re-export saves the engine read + diff
12
+ * regeneration roundtrip and leaves the `.patch` file's mtime alone.
13
+ *
14
+ * Modes are mutually exclusive: exactly one of `--tier <branding>` or
15
+ * `--clear` must be supplied per invocation.
16
+ */
17
+ import { Option } from 'commander';
18
+ import { getProjectPaths } from '../../core/config.js';
19
+ import { appendHistory } from '../../core/destructive.js';
20
+ import { updatePatchMetadata } from '../../core/patch-export.js';
21
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
22
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
23
+ import { toError } from '../../utils/errors.js';
24
+ import { pathExists } from '../../utils/fs.js';
25
+ import { info, intro, outro, warn } from '../../utils/logger.js';
26
+ import { pickDefined } from '../../utils/options.js';
27
+ /**
28
+ * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
29
+ * named patch (or clears the field) and writes the manifest.
30
+ *
31
+ * @param projectRoot - Project root directory
32
+ * @param identifier - Patch filename, ordinal, or manifest `name`
33
+ * @param options - Command options
34
+ */
35
+ export async function patchTierCommand(projectRoot, identifier, options = {}) {
36
+ const isDryRun = options.dryRun === true;
37
+ intro(isDryRun ? 'FireForge patch tier (dry run)' : 'FireForge patch tier');
38
+ // Mode mutex: a single invocation either sets or clears the tier.
39
+ // Combining both is ambiguous — the operator's intent is not obvious
40
+ // and silently picking one would mask the typo.
41
+ const setting = options.tier !== undefined;
42
+ const clearing = options.clear === true;
43
+ if (setting && clearing) {
44
+ throw new InvalidArgumentError('--tier and --clear are mutually exclusive. Pick one mode per invocation.', 'patch tier');
45
+ }
46
+ if (!setting && !clearing) {
47
+ throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
48
+ }
49
+ const paths = getProjectPaths(projectRoot);
50
+ if (!(await pathExists(paths.patches))) {
51
+ throw new GeneralError('Patches directory not found.');
52
+ }
53
+ const manifest = await loadPatchesManifest(paths.patches);
54
+ if (!manifest || manifest.patches.length === 0) {
55
+ throw new GeneralError('No patches in manifest.');
56
+ }
57
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
58
+ if (!target) {
59
+ const available = manifest.patches
60
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
61
+ .join(', ');
62
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
63
+ }
64
+ const before = target.tier;
65
+ const after = setting ? options.tier : undefined;
66
+ if (before === after) {
67
+ info(after === undefined
68
+ ? `${target.filename}: tier is already absent — no change.`
69
+ : `${target.filename}: tier is already "${after}" — no change.`);
70
+ outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch tier (no-op)');
71
+ return;
72
+ }
73
+ const action = after === undefined
74
+ ? `clear tier (was "${before}")`
75
+ : before === undefined
76
+ ? `set tier to "${after}"`
77
+ : `change tier from "${before}" to "${after}"`;
78
+ if (isDryRun) {
79
+ info(`[dry-run] ${target.filename}: would ${action}.`);
80
+ outro('Dry run complete — no changes made');
81
+ return;
82
+ }
83
+ // Single write under the patch directory lock (delegated inside
84
+ // updatePatchMetadata). Setting routes through `updates`; clearing
85
+ // routes through `unsetFields` so TypeScript's exact optional types
86
+ // do not have to carry an explicit `undefined` on the `tier` field.
87
+ if (after !== undefined) {
88
+ await updatePatchMetadata(paths.patches, target.filename, { tier: after });
89
+ }
90
+ else {
91
+ await updatePatchMetadata(paths.patches, target.filename, {}, ['tier']);
92
+ }
93
+ try {
94
+ await appendHistory(paths.patches, {
95
+ operation: 'patch-tier',
96
+ args: {
97
+ filename: target.filename,
98
+ ...(before !== undefined ? { before } : {}),
99
+ ...(after !== undefined ? { after } : {}),
100
+ },
101
+ ...(options.yes === true ? { yes: true } : {}),
102
+ result: 'ok',
103
+ });
104
+ }
105
+ catch (historyError) {
106
+ warn(`History log append failed after patch tier committed (${target.filename}): ${toError(historyError).message}`);
107
+ }
108
+ info(`${target.filename}: ${action}.`);
109
+ outro('Patch tier complete');
110
+ }
111
+ /**
112
+ * Registers the `patch tier` subcommand on the `patch` parent.
113
+ *
114
+ * @param parent - Parent Commander command
115
+ * @param context - Shared CLI registration context
116
+ */
117
+ export function registerPatchTier(parent, context) {
118
+ const { getProjectRoot, withErrorHandling } = context;
119
+ parent
120
+ .command('tier <name>')
121
+ .description('Set or clear PatchMetadata.tier on a single patch (no .patch body rewrite). Use --tier <branding> to set, --clear to remove.')
122
+ .addOption(new Option('--tier <tier>', 'Force the tier override on the patch (only "branding" recognised)').choices(['branding']))
123
+ .option('--clear', 'Remove the tier override (restores tier auto-detection)')
124
+ .option('--dry-run', 'Show what would change without writing')
125
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
126
+ .action(withErrorHandling(async (name, options) => {
127
+ const { tier, ...rest } = options;
128
+ await patchTierCommand(getProjectRoot(), name, {
129
+ ...pickDefined(rest),
130
+ ...(tier !== undefined ? { tier: tier } : {}),
131
+ });
132
+ }));
133
+ }
134
+ //# sourceMappingURL=tier.js.map
@@ -11,6 +11,85 @@ import { InvalidArgumentError } from '../errors/base.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, outro, success, warn } from '../utils/logger.js';
13
13
  import { runPatchLint } from './export-shared.js';
14
+ /**
15
+ * Computes the effective `tier` and `lintIgnore` carrying both the
16
+ * patch's existing values and the CLI flag overrides. Pure helper —
17
+ * extracted from {@link reExportFilesInPlace} both to share with the
18
+ * standard re-export path conceptually and to keep the orchestrator
19
+ * function under the per-file LOC budget.
20
+ *
21
+ * Tier resolution: the CLI flag takes precedence; the patch's existing
22
+ * tier is the fallback. Lint-ignore resolution: union of the patch's
23
+ * existing list and the CLI flag values, de-duplicated; an empty
24
+ * result returns `undefined` so the caller can drop the field rather
25
+ * than write an empty array.
26
+ */
27
+ function resolveEffectiveTierAndLintIgnore(target, options) {
28
+ const existingIgnoreSet = new Set(target.lintIgnore ?? []);
29
+ const flagIgnoreSet = new Set(options.lintIgnore ?? []);
30
+ const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
31
+ const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
32
+ const effectiveTier = options.tier ?? target.tier;
33
+ return { effectiveTier, effectiveLintIgnore, flagIgnoreSet };
34
+ }
35
+ /**
36
+ * Projects the cross-patch context (replace the target entry with its
37
+ * shrunken self), runs the patch-queue lint against the projection,
38
+ * and returns a conflict report only for regressions introduced *by*
39
+ * this shrink. Pre-existing cross-patch errors are surfaced as a
40
+ * non-blocking warning so the user does not walk away thinking the
41
+ * queue is clean. Extracted from {@link reExportFilesInPlace} to keep
42
+ * the orchestrator function under the per-file LOC budget.
43
+ */
44
+ async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedDiff) {
45
+ const baseCtx = await buildPatchQueueContext(patchesDir);
46
+ const projectedNewFiles = new Map();
47
+ for (const path of detectNewFilesInDiff(projectedDiff)) {
48
+ projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
49
+ }
50
+ const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
51
+ const projectedEntries = baseCtx.entries.map((entry) => {
52
+ if (entry.filename !== targetFilename)
53
+ return entry;
54
+ return {
55
+ ...entry,
56
+ diff: projectedDiff,
57
+ newFiles: projectedNewFiles,
58
+ modifiedFileAdditions: projectedModifiedFileAdditions,
59
+ };
60
+ });
61
+ const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
62
+ const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
63
+ const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
64
+ if (baselineIssues.length > 0 && regressions.length === 0) {
65
+ warn(`Note: projected queue still has ${baselineIssues.length} pre-existing ` +
66
+ `cross-patch error(s) unrelated to this shrink. Run "fireforge verify" to list them.`);
67
+ }
68
+ if (regressions.length === 0)
69
+ return null;
70
+ return {
71
+ reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
72
+ details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
73
+ };
74
+ }
75
+ /**
76
+ * Builds the `Partial<PatchMetadata>` payload for the `--files` write,
77
+ * folding in the CLI flag overrides for `tier` and `lintIgnore` only
78
+ * when the operator actually asked for them. Extracted to keep
79
+ * {@link reExportFilesInPlace} under the per-file LOC budget.
80
+ */
81
+ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet) {
82
+ const updates = {
83
+ filesAffected: actualProjectedFiles,
84
+ };
85
+ if (options.tier !== undefined) {
86
+ updates.tier = options.tier;
87
+ }
88
+ if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
89
+ updates.lintIgnore = effectiveLintIgnore;
90
+ }
91
+ return updates;
92
+ }
14
93
  /**
15
94
  * Handles `re-export --files` end-to-end: computes the projected diff,
16
95
  * runs the per-patch and cross-patch lint against a context in which the
@@ -77,50 +156,13 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
77
156
  // have to choose between `--skip-lint` (blunt) and the full rebase path.
78
157
  // `target.tier` threads the explicit branding-threshold opt-in for
79
158
  // the branding patch that also touches a non-allowlisted sibling.
80
- const ignoreChecks = target.lintIgnore?.length ? new Set(target.lintIgnore) : undefined;
81
- await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, target.tier);
82
- // Project the cross-patch context: replace the target entry with its
83
- // would-be shrunken self (new diff + new newFiles + new
84
- // modifiedFileAdditions). The projected entry must repopulate both
85
- // source-site maps so the forward-import rule sees imports the
86
- // shrunken diff would add or stop adding — consistently with how a
87
- // real rebuild would see them.
88
- const baseCtx = await buildPatchQueueContext(paths.patches);
89
- const projectedNewFiles = new Map();
90
- for (const path of detectNewFilesInDiff(projectedDiff)) {
91
- projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
92
- }
93
- const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
94
- const projectedEntries = baseCtx.entries.map((entry) => {
95
- if (entry.filename !== target.filename)
96
- return entry;
97
- return {
98
- ...entry,
99
- diff: projectedDiff,
100
- newFiles: projectedNewFiles,
101
- modifiedFileAdditions: projectedModifiedFileAdditions,
102
- };
103
- });
104
- // Baseline-vs-projected diffing: only regressions introduced *by* this
105
- // shrink should block. A pre-existing cross-patch error elsewhere in
106
- // the queue must not prevent the user from shrinking an unrelated
107
- // patch (which is often exactly the tool they reach for to repair
108
- // such a queue).
109
- const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
110
- const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
111
- const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
112
- const conflicts = regressions.length > 0
113
- ? {
114
- reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
115
- details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
116
- }
117
- : null;
118
- // Surface pre-existing errors as a non-blocking warning so the user
119
- // doesn't walk away thinking the queue is clean.
120
- if (baselineIssues.length > 0 && regressions.length === 0) {
121
- warn(`Note: projected queue still has ${baselineIssues.length} pre-existing ` +
122
- `cross-patch error(s) unrelated to this shrink. Run "fireforge verify" to list them.`);
123
- }
159
+ // CLI flags `--tier` and `--lint-ignore` participate too, with
160
+ // append/union semantics on the lint-ignore list (matching the
161
+ // standard re-export path).
162
+ const { effectiveTier, effectiveLintIgnore, flagIgnoreSet } = resolveEffectiveTierAndLintIgnore(target, options);
163
+ const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
164
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
165
+ const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
124
166
  // Shrinks are destructive (previously-owned files become unmanaged).
125
167
  // Additive-only changes still deserve a prompt because --files asserts
126
168
  // an authoritative file set.
@@ -163,7 +205,8 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
163
205
  // directory lock as the mutation (via the onCommitted hook) so two
164
206
  // concurrent re-exports cannot interleave records and a crash between
165
207
  // mutation and append cannot orphan the audit trail.
166
- await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, { filesAffected: actualProjectedFiles }, async () => {
208
+ const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
209
+ await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, filesUpdates, async () => {
167
210
  await appendHistory(paths.patches, {
168
211
  operation: 're-export-files',
169
212
  args: {
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
3
  import { confirm, multiselect } from '@clack/prompts';
4
+ import { Option } from 'commander';
4
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
6
  import { isGitRepository } from '../core/git.js';
6
7
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
@@ -204,10 +205,29 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
204
205
  // The paired `patch.tier` threads the explicit branding-threshold
205
206
  // opt-in the same way, for the branding patch that also touches a
206
207
  // non-allowlisted registration sibling.
207
- const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
208
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, patch.tier);
208
+ //
209
+ // The CLI flags `--tier` and `--lint-ignore` participate too, with
210
+ // append/union semantics on the lint-ignore list (the operator's
211
+ // intuition for "I want this patch to also suppress X" — explicit
212
+ // removal lives on the `fireforge patch lint-ignore` subcommand).
213
+ // Computed before the lint pass so the new intent takes effect on
214
+ // this invocation, not the next one.
215
+ const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
216
+ const flagIgnoreSet = new Set(options.lintIgnore ?? []);
217
+ const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
218
+ const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
219
+ const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
220
+ const effectiveTier = options.tier ?? patch.tier;
221
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
209
222
  if (isDryRun) {
210
223
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
224
+ if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
225
+ info(`[dry-run] ${patch.filename}: tier would become ${effectiveTier}`);
226
+ }
227
+ const addedIgnores = [...flagIgnoreSet].filter((id) => !existingIgnoreSet.has(id));
228
+ if (addedIgnores.length > 0) {
229
+ info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
230
+ }
211
231
  }
212
232
  else {
213
233
  // Atomic body + manifest update under a single patch-directory lock.
@@ -215,9 +235,16 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
215
235
  // sequence allows a concurrent `resolve` / `rebase --continue` / `patch
216
236
  // compact` / `patch reorder` to rewrite the manifest between the two
217
237
  // writes and leave patch body and `filesAffected` disagreeing.
218
- await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, {
238
+ const updates = {
219
239
  filesAffected: currentFilesAffected,
220
- });
240
+ };
241
+ if (options.tier !== undefined) {
242
+ updates.tier = options.tier;
243
+ }
244
+ if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
245
+ updates.lintIgnore = effectiveLintIgnore;
246
+ }
247
+ await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates);
221
248
  // Keep the in-memory manifest in sync so subsequent iterations (notably
222
249
  // `--all --scan`, where `getClaimedFiles` reads from this manifest) see
223
250
  // the just-written `filesAffected`. The on-disk write above is the
@@ -228,7 +255,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
228
255
  if (existingEntry) {
229
256
  manifest.patches[patchIndex] = {
230
257
  ...existingEntry,
231
- filesAffected: currentFilesAffected,
258
+ ...updates,
232
259
  };
233
260
  }
234
261
  }
@@ -291,6 +318,15 @@ export async function reExportCommand(projectRoot, patches, options) {
291
318
  throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
292
319
  }
293
320
  }
321
+ // --tier and --lint-ignore are per-patch metadata edits; combining them
322
+ // with --all silently rewrites every patch's tier/ignore list, which is
323
+ // virtually always wrong (different patches have different shapes).
324
+ // Refuse the combination so the operator must enumerate the targets.
325
+ const usingTierFlag = options.tier !== undefined;
326
+ const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
327
+ if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
328
+ throw new InvalidArgumentError('--tier and --lint-ignore require explicit patch identifiers and cannot be combined with --all (different patches typically need different metadata).', '--all');
329
+ }
294
330
  const paths = getProjectPaths(projectRoot);
295
331
  // Check if engine exists
296
332
  if (!(await pathExists(paths.engine))) {
@@ -396,8 +432,15 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
396
432
  .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
397
433
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
398
434
  .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
435
+ .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
436
+ .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
399
437
  .action(withErrorHandling(async (patches, options) => {
400
- await reExportCommand(getProjectRoot(), patches, pickDefined(options));
438
+ const { tier, lintIgnore, ...rest } = options;
439
+ await reExportCommand(getProjectRoot(), patches, {
440
+ ...pickDefined(rest),
441
+ ...(tier !== undefined ? { tier: tier } : {}),
442
+ ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
443
+ });
401
444
  }));
402
445
  }
403
446
  //# sourceMappingURL=re-export.js.map
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join } from 'node:path';
5
5
  import { GitError } from '../errors/git.js';
@@ -35,6 +35,16 @@ export async function getFileDiff(repoDir, filePath) {
35
35
  */
36
36
  export async function generateNewFileDiff(repoDir, filePath) {
37
37
  const fullPath = join(repoDir, filePath);
38
+ // Defensive check: a directory here means a caller bypassed the
39
+ // expansion layers and handed the leaf reader a path it cannot
40
+ // read. Surface it with an actionable message naming the offending
41
+ // path rather than the raw `EISDIR` that `readText` would throw —
42
+ // recurring bug class (see the belt-and-suspenders note in
43
+ // `getDiffForFilesAgainstHead`).
44
+ const fileStat = await stat(fullPath);
45
+ if (fileStat.isDirectory()) {
46
+ throw new GitError(`expected a file but found a directory at '${filePath}' — caller must expand directory entries before diffing`, `hash-object ${filePath}`);
47
+ }
38
48
  const content = await readText(fullPath);
39
49
  // Compute the abbreviated git blob hash for the index line
40
50
  let blobHash = '0000000000';
@@ -212,7 +222,29 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
212
222
  }
213
223
  continue;
214
224
  }
215
- if (!(await pathExists(join(repoDir, file)))) {
225
+ const fullPath = join(repoDir, file);
226
+ if (!(await pathExists(fullPath))) {
227
+ continue;
228
+ }
229
+ // Second defence against the EISDIR regression: a non-HEAD path
230
+ // that exists on disk is usually a new file, but can also be a
231
+ // directory that arrived without the trailing slash
232
+ // `expandUntrackedDirectoryEntries` would have produced (caller
233
+ // stripped it, submodule entry, tracked-file-replaced-by-dir).
234
+ // Expand it via the same helper used by the slash branch and
235
+ // recurse so each contained file is diffed individually; fail
236
+ // loud when the directory has no readable content rather than
237
+ // silently skipping it.
238
+ const fileStat = await stat(fullPath);
239
+ if (fileStat.isDirectory()) {
240
+ const innerFiles = await getUntrackedFilesInDir(repoDir, file);
241
+ if (innerFiles.length === 0) {
242
+ throw new GitError(`'${file}' is a directory with no untracked content (submodule or gitignored?) — cannot diff as a file`, `ls-files --others -- ${file}`);
243
+ }
244
+ const innerDiff = await getDiffForFilesAgainstHead(repoDir, innerFiles);
245
+ if (innerDiff.trim()) {
246
+ diffs.push(innerDiff);
247
+ }
216
248
  continue;
217
249
  }
218
250
  const diff = await generateNewFileDiff(repoDir, file);
@@ -21,6 +21,10 @@ export interface CommitExportedPatchInput {
21
21
  diff: string;
22
22
  filesAffected: string[];
23
23
  sourceEsrVersion: string;
24
+ /** Optional `PatchMetadata.tier` opt-in (only `"branding"` recognised). */
25
+ tier?: 'branding';
26
+ /** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
27
+ lintIgnore?: string[];
24
28
  }
25
29
  export interface CommitExportedPatchResult {
26
30
  patchFilename: string;
@@ -88,13 +92,71 @@ export type UpdatePatchCommittedHook = () => Promise<void>;
88
92
  * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
89
93
  */
90
94
  export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
95
+ /**
96
+ * Optional `PatchMetadata` keys safe to clear via the helpers below.
97
+ * Required keys (filename, order, etc.) are excluded by construction so
98
+ * an over-eager `unsetFields: ['filename']` cannot delete a field the
99
+ * manifest validator requires. Add new keys here only when they become
100
+ * optional on the type.
101
+ */
102
+ export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
91
103
  /**
92
104
  * Updates metadata for a patch in the manifest.
105
+ *
106
+ * Required-field updates go through the `updates` partial. Clearing an
107
+ * optional field (e.g. removing the `tier` override) goes through
108
+ * `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
109
+ * not let `Partial<PatchMetadata>` carry an explicit `undefined` value
110
+ * for fields whose declared type does not include `undefined`. The
111
+ * implementation deletes the listed keys from the merged record before
112
+ * writing, so the on-disk JSON omits them and the validator's
113
+ * "preserve only when present" contract is preserved.
114
+ *
93
115
  * @param patchesDir - Path to the patches directory
94
116
  * @param filename - Patch filename
95
- * @param updates - Partial metadata updates
117
+ * @param updates - Field values to set. Pass an empty object when only
118
+ * clearing fields.
119
+ * @param unsetFields - Optional fields to remove from the entry (so
120
+ * serialization drops them).
121
+ */
122
+ export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
123
+ /**
124
+ * Return shape from a {@link mutatePatchMetadata} mutator.
125
+ */
126
+ export interface PatchMetadataMutation {
127
+ /** Field values to set on the entry. */
128
+ set?: Partial<PatchMetadata>;
129
+ /** Optional fields to remove from the entry entirely. */
130
+ unset?: ReadonlyArray<ClearablePatchMetadataField>;
131
+ }
132
+ /**
133
+ * Result of a successful {@link mutatePatchMetadata} call.
96
134
  */
97
- export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>): Promise<void>;
135
+ export interface PatchMetadataMutationResult {
136
+ /** Pre-mutation snapshot of the patch's metadata. */
137
+ before: PatchMetadata;
138
+ /** Post-mutation state of the patch's metadata. */
139
+ after: PatchMetadata;
140
+ }
141
+ /**
142
+ * Reads a patch's metadata under the directory lock, applies a mutator
143
+ * function to compute the update, and writes the result back — all
144
+ * under a single lock so a concurrent writer cannot interleave a
145
+ * read-modify-write cycle. Useful for operations that need to compute
146
+ * the new value from the old (e.g. unioning a `lintIgnore` list,
147
+ * removing a specific entry), which {@link updatePatchMetadata}'s flat
148
+ * merge cannot express on its own.
149
+ *
150
+ * The mutator returns `{ set, unset }` so it can both write fields
151
+ * and drop optional ones. `set` and `unset` are merged before write:
152
+ * `set` runs first via spread, then `unset` deletes the listed keys.
153
+ *
154
+ * @returns The pre/post metadata pair when the patch is found and the
155
+ * write succeeds; `null` when the manifest is missing or the named
156
+ * patch is not in it. Callers should treat `null` as "no-op, nothing
157
+ * to log".
158
+ */
159
+ export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
98
160
  /**
99
161
  * Finds patches that are completely superseded by newer patches.
100
162
  * A patch is superseded if all its affected files are covered by newer patches.
@@ -196,6 +258,19 @@ export interface PlanExportInput {
196
258
  description: string;
197
259
  filesAffected: string[];
198
260
  sourceEsrVersion: string;
261
+ /**
262
+ * Optional `PatchMetadata.tier` opt-in carried from the CLI flag.
263
+ * Only `"branding"` is currently recognised. When provided the field
264
+ * is written into the new patch's metadata; when absent the field
265
+ * stays unset and tier resolution falls back to auto-detection.
266
+ */
267
+ tier?: 'branding';
268
+ /**
269
+ * Optional `PatchMetadata.lintIgnore` carried from the CLI flag.
270
+ * Empty arrays are treated as "field absent" — the validator only
271
+ * preserves the field when it has at least one entry.
272
+ */
273
+ lintIgnore?: string[];
199
274
  }
200
275
  /**
201
276
  * Read-only planning function — computes everything a real export would